Rozdział 16. Programy Javy działające na serwerze — gniazda

Gniazda stanowią podstawę wszelkich protokołów sieciowych. JDBC, RMI, CORBA, EJB oraz niezwiązana z Javą technologia RPC (wywoływanie zdalnych procedur) stanowiąca podstawę NFS (sieciowego systemu plików) — wszystkie te technologie powstały poprzez wspólne wykorzystanie wielu różnych rodzajów gniazd. Połączenia wykorzystujące gniazda można implementować w wielu różnych językach programowania, a nie tylko w Javie; często są w tym celu wykorzystywane takie języki jak C, C++, Perl oraz Python, a w wielu innych jest to możliwe. Klient lub serwer napisany w dowolnym z tych języków może się komunikować ze swym funkcjonalnym przeciwieństwem (odpowiednio serwerem lub klientem) napisanym w dowolnym innym z tych języków. Dlatego też nawet jeśli w efekcie mamy korzystać z usług działających na wyższym poziomie, takich jak RMI, JDBC, CORBA czy też EJB, warto poświęcić trochę czasu i przyjrzeć się sposobom działania i użycia klasy ServerSocket.

W niniejszym rozdziale w pierwszej kolejności przyjrzymy się samej klasie ServerSocket, a następnie zajmiemy się różnym sposobom zapisywania informacji przy użyciu obiektów tej klasy. Pod koniec przedstawię pełną implementację w pełni funkcjonalnego serwera sieciowego napisanego w Javie. Będzie to serwer pogawędek komunikujący się z klientem przedstawionym w Rozdział 13.

Klasa ServerSocket reprezentuje „drugi koniec” połączenia — serwer, który cierpliwie czeka, aż jakiś klient zechce nawiązać z nim połączenie. Podczas tworzenia obiektów klasy ServerSocket wystarczy podać numer portu[65]. Serwer nie musi z własnej inicjatywy nawiązywać połączeń z innymi komputerami, dlatego też nie jest mu potrzebny adres, który był konieczny w przypadku tworzenia gniazd stosowanych w klientach.

Zakładając, że konstruktor klasy ServerSocket nie zgłosi żadnych wyjątków, możemy wykonać kolejne operacje. Następnym krokiem jest oczekiwanie na jakieś działania ze strony klienta — w tym celu wywoływana jest metoda accept(). Jej wywołanie wstrzymuje dalszą realizację programu aż do momentu, gdy jakiś klient nawiąże połączenie z serwerem. Gdy połączenie zostanie nawiązane, metoda accept() zwraca obiekt Socket (nie ServerSocket) połączony z obiektem Socket istniejącym w kliencie (lub z jego odpowiednikiem, jeśli klient został napisany w innym języku programowania) w sposób umożliwiający dwukierunkową wymianę informacji. Kod źródłowy serwera wykorzystującego gniazda przedstawiłem na Przykład 16-1.

W przypadku tworzenia normalnego programu gniazdo byłoby wykorzystywane zarówno do odczytu, jak i do zapisu, jak pokazałem w następnej recepturze.

Może się zdarzyć, że będziemy chcieli oczekiwać na połączenia, używając konkretnego interfejsu sieciowego. Choć zazwyczaj adresy sieciowe są uważane za adresy komputerów, to jednak w rzeczywistości nie są one tym samym. Adres sieciowy to adres konkretnej karty sieciowej lub połączenia interfejsu sieciowego na danym komputerze. Komputery biurowe, laptopy, palmtopy czy też telefony komórkowe mogą mieć tylko jeden interfejs, a co z tego wynika — jeden adres sieciowy. Jednak duże komputery pełniące funkcję serwerów mogą mieć dwa lub nawet więcej interfejsów sieciowych; zazwyczaj dzieje się tak w przypadkach, gdy komputery te są podłączone do kilku sieci. Router sieciowy (bądź to urządzenie specjalistyczne, takie jak router firmy Cisco, bądź też komputer ogólnego przeznaczenia, na przykład z zainstalowanym systemem Unix) to urządzenie posiadające interfejsy łączące je z wieloma różnymi sieciami i posiadające zarówno możliwości, jak i zezwolenie administratora do przekazywania pakietów z jednej sieci do drugiej. Program działający na takim komputerze może mieć za zadanie udostępnianie usług wyłącznie dla sieci wewnętrznej lub wyłącznie dla sieci zewnętrznej. Jednym ze sposobów osiągnięcia tego celu jest określenie interfejsu sieciowego, którego program będzie używał do oczekiwania i odbierania połączeń z klientami. Załóżmy, że chcielibyśmy, by nasze strony WWW miały inny wygląd w przypadku pobierania ich przez firmowy intranet, a inny dla użytkowników zewnętrznych. Ze względów bezpieczeństwa obie te usługi nie byłyby zapewne uruchamiane na tym samym komputerze. Gdyby jednak zaistniała konieczność uruchomienia ich w ten sposób, to można by to zrobić, podając adres interfejsu sieciowego jako argument wywołania konstruktora klasy ServerSocket.

Niemniej jednak, chcąc użyć tej formy konstruktora, nie można podawać nazwy adresu sieciowego w formie łańcucha znaków, jak to było w przypadku tworzenia obiektów Socket. Ta wersja konstruktora wymaga skonwertowania nazwy adresu sieciowego na obiekt InetAddress. Konieczne jest także określenie argumentu backlog — liczby połączeń, które będzie można umieścić w kolejce połączeń przeznaczonych do odebrania; po przekroczeniu podanej liczby połączeń do kolejnych klientów będzie odsyłana informacja, że serwer jest zbyt obciążony. Pełny kod programu tworzącego gniazda tego typu przedstawiłem na Przykład 16-2.

Metoda InetAddress.getByName() poszukuje komputera o podanej nazwie w sposób zależny od używanego systemu operacyjnego — odwołując się do odpowiednich plików konfiguracyjnych przechowywanych w katalogu /etc lub \windows bądź też korzystając z jakiejś usługi wyszukiwawczej, takiej jak DNS (ang. Domain Name System). Jeśli będziesz musiał zmodyfikować te dane, to zanim się za to zabierzesz, zajrzyj do dobrej książki o administracji sieciami i obsłudze systemów operacyjnych.

W klientach wykorzystujących gniazda, których przykłady przedstawiłem w Rozdział 13., wywoływane były metody getInputStream() oraz getOutputStream(). Serwery, których przykłady zaprezentowałem w tym rozdziale, postępują tak samo. Podstawowa różnica polega na tym, iż w tym przypadku gniazdo (obiekt Socket) jest zwracane przez metodę accept() obiektu ServerSocket. Kolejną różnicą jest to, że serwer zazwyczaj tworzy lub modyfikuje dane, po czym zapisuje je i przesyła do klienta. Przykład 16-3 zawiera kod prostego serwera Echo, z którym można się połączyć przy użyciu programu Echo przedstawionego w „13.4. Odczyt i zapis danych tekstowych”. Serwer obsługuje jedno pełne połączenie z klientem, po czym ponownie wywołuje metodę accept(), oczekując na kolejne połączenia.

Przykład 16-3. /server/EchoServer.java

public class EchoServer {
    /** Gniazdo używane przez serwer do komunikacji z klientem. */
    protected ServerSocket sock;
    /** Domyślny numer portu. */
    public final static int ECHOPORT = 7;
    /** Flaga kontrolująca testowanie. */
    protected boolean debug = true;

    /** main: stworzenie i uruchomienie. */
    public static void main(String[] args) {
        int p = ECHOPORT;
        if (args.length == 1) {
            try {
                p = Integer.parseInt(args[0]);
            } catch (NumberFormatException e) {
                System.err.println("Sposób użycia: EchoServer [port#]");
                System.exit(1);
            }
        }
        new EchoServer(p).handle();
    }

    /** Konstruktor, tworzy serwer EchoServer używający podanego portu. */
    public EchoServer(int port) {
        try {
            sock = new ServerSocket(port);
        } catch (IOException e) {
            System.err.println("Błąd wejścia-wyjścia podczas konfiguracji.");
            System.err.println(e);
            System.exit(1);
        }
    }

    /** Metoda obsługująca połączenie. */
    protected void handle() {
        Socket ios = null;
        BufferedReader is = null;
        PrintWriter os = null;
        while (true) {
            try {
                System.out.println("Czekamy na klienta...");
                ios = sock.accept();
                System.err.println("Odebrano połączenie od " +
                    ios.getInetAddress().getHostName());
                is = new BufferedReader(
                    new InputStreamReader(ios.getInputStream(), "8859_1"));
                os = new PrintWriter(
                        new OutputStreamWriter(
                            ios.getOutputStream(), "8859_1"), true);
                String echoLine;
                while ((echoLine = is.readLine()) != null) {
                    System.err.println("Odczytano " + echoLine);
                    os.print(echoLine + "\r\n");
                    os.flush();
                    System.err.println("Zapisano " + echoLine);
                }
                System.err.println("Gotowe!");
            } catch (IOException e) {
                System.err.println(e);
            } finally {
                try {
                    if (is != null)
                        is.close();
                    if (os != null)
                        os.close();
                    if (ios != null)
                        ios.close();
                } catch (IOException e) {
                    // Te przypadki są rzadkie, lecz mogą wskazywać,
                    // że klient został przedwcześnie zamknięty,
                    // dysk został wypełniony, co nie zostało wykryte
                    // aż do chwili zamknięcia programu, i tak dalej.
                    System.err.println(
                                "Błąd wejścia-wyjścia podczas zamykania");
                }
            }
        }
        /*NIEOSIĄGANE*/
    }
}

Niektóre osoby uważane za autorytety w dziedzinie programów sieciowych radzą, by w przypadku przesyłania łańcuchów znaków przez dowolne połączenia sieciowe zakańczać je kombinacją znaków \r\n (powrót karetki i nowy wiersz); wymaga tego wiele protokołów. To tłumaczy, dlaczego kombinacja ta znalazła się w kodzie programu. Rozwiązanie to jest zalecane, gdyż niektóre programy, takie jak programy działające w systemie DOS lub aplikacje przypominające program Telnet, mogą oczekiwać obu tych znaków. Z drugiej strony, jeśli sami tworzymy oba porozumiewające się ze sobą programy, to możemy korzystać z metod println(), a przed odczytem zawsze w jawny sposób opróżniać bufor, wywołując metodę flush(). Metodę tę wywołujemy w celu uniknięcia wzajemnej blokady, która może wystąpić, jeśli oba komunikujące się programy będą jednocześnie starać się odczytać dane, a w jednym z nich bufor PrintWriter nie będzie pusty.

W przypadkach gdy konieczne jest przetwarzanie danych binarnych, zamiast czytelników i pisarzy można użyć strumieni dostępnych w pakiecie java.io. Ja na przykład potrzebowałem serwera współpracującego z programem DaytimeBinary przedstawionym w „13.5. Odczyt i zapis danych binarnych”. Wykorzystanie tego programu powinno dawać następujące wyniki:

C:\javasrc\network>java network.DaytimeBinary
Czas na komputerze zdalnym: 3194953367
BASE_DIFF = 2208988800
Różnica czasów == 952284799
Czas na komputerze localhost to Sun Mar 08 19:33:19 GMT 2014

C:\javasrc\network>time /t
7:33:17.43p

C:\javasrc\network>date /t
03-08-2014

C:\javasrc\network>

Cóż, tak się składa, że dysponuję odpowiednim programem, dlatego też przedstawię go na Przykład 16-4. Warto zwrócić uwagę, że program ten w bezpośredni sposób korzysta z pewnych stałych publicznych zdefiniowanych w klasie klienta. Zazwyczaj stałe takie są definiowane w klasie serwera i używane przez klienta, jednak ja chciałem je najpierw przedstawić w kodzie klienta.

Przykład 16-4. /network/DaytimeServer.java (binarny protokół serwera)

public class DaytimeServer {
    /** Numer gniazda serwera. */
    ServerSocket sock;
    /** Domyślnie używany numer portu. */
    public final static int PORT = 37;

    /** Stworzenie obiektu i jego uruchomienie. */
    public static void main(String[] argv) {
        new DaytimeServer(PORT).runService();
    }

    /** Konstruktor klasy DaytimeServer operujący na porcie
     * o podanym numerze. */
    public DaytimeServer(int port) {
        try {
            sock = new ServerSocket(port);
        } catch (IOException e) {
            System.err.println(
                      "Błąd wejścia-wyjścia podczas inicjalizacji\n" + e);
            System.exit(1);
        }
    }

    /** Metoda obsługuje połączenia. */
    protected void runService() {
        Socket ios = null;
        DataOutputStream os = null;
        while (true) {
            try {
                System.out.println("Oczekujemy na połączenia na porcie "
                        + PORT);
                ios = sock.accept();
                System.err.println("Odebrano połączenie z " +
                    ios.getInetAddress().getHostName());
                os = new DataOutputStream(ios.getOutputStream());
                long time = System.currentTimeMillis();

                time /= RDateClient.MSEC;    // Protokół Daytime przesyła
                                             // ilość sekund.

                // Konwertujemy na czas używany w Javie.
                time += RDateClient.BASE_DIFF;

                // Zapisujemy, ograniczając zakres przy rzutowaniu do
                // typu int, co jest konieczne, gdyż internetowy protokół
                // Daytime wymaga użycia czterech bajtów. Rozwiązanie to
                // przestanie działać poprawnie w roku 2038 wraz z wszelkimi
                // systemami operacyjnymi, w których czas przechowywany jest
                // jako zmienna całkowita (int) wyrażająca liczbę sekund,
                // jakie upłynęły od początku roku 1970.
                // Przypomnij sobie we właściwym czasie, że to właśnie w tej
                // książce po raz pierwszy przeczytałeś o problemie
                // roku 2038!
                os.writeInt((int)time);
                os.close();
            } catch (IOException e) {
                System.err.println(e);
            }
        }
    }
}

W przypadku tworzenia oprogramowania w języku C istnieje kilka sposobów, aby zapewnić serwerowi możliwość jednoczesnej obsługi kilku klientów. Jednym z nich jest użycie specjalnych funkcji systemowych select() lub poll(), które informują serwer, gdy dowolna z grup deskryptorów plików lub gniazd jest gotowa do odczytu, gotowa do zapisu lub gdy pojawił się jakiś błąd. Jeśli serwer napisany w języku C przekaże w wywołaniu którejś z tych funkcji specjalne gniazdo spotkania (odpowiednik klasy ServerSocket), to będzie mógł odczytywać dane z dowolnej liczby klientów — i to w dowolnej kolejności. Java nie udostępnia żadnej z tych funkcji systemowych, gdyż na niektórych platformach systemowych, na jakich działa język, zaimplementowanie tych funkcji jest bardzo trudne. Java korzysta natomiast z pewnego mechanizmu ogólnego przeznaczenia określanego mianem wątków (klasa Thread). Zagadnienia związane z wykorzystaniem wątków przedstawiłem w „22.11. Wielowątkowy serwer sieciowy”. Wątki są w rzeczywistości jednym z kolejnych mechanizmów, z których programiści używający języka C mogą korzystać niemal na wszystkich dostępnych platformach komputerowych i systemowych. Za każdym razem, gdy program odbierze nowe połączenie zwrócone przez obiekt ServerSocket, natychmiast jest tworzony i uruchamiany nowy obiekt wątku, przeznaczony do obsługi połączenia z danym klientem[66].

W języku Java kod implementujący proces odebrania połączenia jest bardzo prosty, no, może za wyjątkiem obsługi wyjątków IOException:

/** Główna pętla serwera. */
void runServer() {
    while (true) {
        try {
            Socket clntSock = sock.accept();
            new Handler(clntSock).start();
        }
        catch (IOException e) {
            System.err.println(e);
        }
    }
}

W celu wykorzystania wątku należy stworzyć klasę potomną klasy Thread lub zaimplementować w wybranej klasie interfejs Runnable. Aby powyższy fragment kodu działał zgodnie z naszymi założeniami i oczekiwaniami, Handler musi być klasą potomną klasy Thread. Gdyby klasa Handler jedynie implementowała interfejs Runnable, to kod powinien przekazać kopię obiektu implementującego ten interfejs w wywołaniu konstruktora klasy Thread, jak pokazałem w poniższym fragmencie kodu:

Thread t = new Thread(new Handler(clntSock));
t.start();

Jednak zgodnie z tym, co pokazałem na poprzednich listingach, przy tworzeniu obiektu klasy Handler jest używane zwyczajne gniazdo zwrócone przez wywołanie metody accept(). A zatem obiekt ten może w normalny sposób wywoływać metody getInputStream() oraz getOutputStream() gniazda i prowadzić normalną wymianę danych z klientem. Za chwilę przedstawię pełną implementację serwera programu Echo wykorzystującego wątki; najpierw jednak pokażę przykładową sesję działania tego serwera:

$ java network.EchoServerThreaded
EchoServerThreaded oczekuje na połączenia.
Otwieram połączenie: Socket[addr=localhost/127.0.0.1,port=2117,localport=7]
Otwieram połączenie: Socket[addr=darian/192.168.1.50,port=13386,localport=7]
Otwieram połączenie: Socket[addr=darian/192.168.1.50,port=22162,localport=7]
Połączenie zamknięte: Socket[addr=darian/192.168.1.50,port=22162,localport=7]
Połączenie zamknięte: Socket[addr=darian/192.168.1.50,port=13386,localport=7]
Połączenie zamknięte: Socket[addr=localhost/127.0.0.1,port=2117,localport=7]

Na powyższym listingu pierwsze połączenie z serwerem nawiązałem przy użyciu programu EchoClient, a kolejne dwa przy użyciu standardowego programu Telnet, przy czym były one nawiązywane, gdy pierwsze połączenie wciąż istniało. Serwer współbieżnie obsługiwał wszystkie połączenia, odsyłając komunikaty przesyłane przez pierwszego klienta z powrotem do niego i podobnie postępując z komunikatami przesyłanymi przez pozostałe dwa programy. A zatem, mówiąc krótko: wszystko działa. Sesję prowadzoną przy użyciu programu EchoClient zakończyłem, przesyłając znak końca pliku, a pozostałe dwie — prowadzone za pośrednictwem programu Telnet — z wykorzystaniem standardowego sposobu rozłączania. Kod źródłowy serwera przedstawiłem na Przykład 16-6.

Konieczność obsługi znacznej liczby transakcji może doprowadzić do problemów z efektywnością działania. Wynikają one z faktu, że nawiązanie połączenia z każdym nowym klientem wiąże się z utworzeniem obiektu Handler. Istnieje jeszcze drugie, alternatywne rozwiązanie, które można zastosować, jeśli możliwości pracy współbieżnej, jakimi powinien dysponować serwer, są znane lub jeśli można je przewidzieć. Polega ono na utworzeniu odpowiedniej liczby wątków już podczas uruchamiania serwera. Jednak jak w takim przypadku należy zarządzać dostępem tych wątków do pojedynczego obiektu ServerSocket? Po przejrzeniu dokumentacji klasy ServerSocket okazuje się, że metoda accept() nie jest synchronizowana, co oznacza, że w tej samej chwili może ją wywoływać dowolna liczba wątków. To z kolei może doprowadzić do poważnych problemów. Metoda accept() aktualizuje pewne dane globalne, dlatego też aby zapewnić, że w danej chwili będzie ją mógł wykonywać tylko jeden wątek, umieściłem jej wywołanie wewnątrz instrukcji synchronized. W przypadku gdy żaden klient nie nawiązał połączenia z serwerem, to jeden (losowo wybrany) wątek będzie wykonywał metodę accept() obiektu ServerSocket, oczekując na połączenie, natomiast pozostałe n – 1 wątków będzie oczekiwać na zakończenie wywołania. W momencie gdy pierwszy wątek nawiąże połączenie, metoda accept() się zakończy, a wątek „zajmie się” obsługą transmisji. Dzięki temu metoda accept() przestanie być zablokowana i inny losowo wybrany wątek będzie mógł ją wywołać. Metoda run() każdego wątku zawiera pętlę nieskończoną, która rozpoczyna się od wywołania metody accept(), a następnie wykonuje operacje związane z obsługą wymiany informacji z klientem. Rozwiązanie to umożliwia nieco szybsze rozpoczynanie transmisji danych, lecz wiąże się ono z dłuższym czasem uruchamiania samego serwera. Wykorzystanie tego rozwiązania pozwala także uniknąć konieczności tworzenia obiektu Handler lub Thread za każdym razem, gdy odbierane jest nowe żądanie. Metoda ta przypomina sposób działania popularnego serwera Apache, choć program ten tworzy określoną liczbę (pulę) identycznych procesów (a nie wątków) służących do obsługi odbieranych połączeń. Na Przykład 16-7 przedstawiłem kod źródłowy zmodyfikowanej wersji serwera EchoServerThreaded działającej w opisany powyżej sposób.

Przykład 16-7. /network/EchoServerThreaded2.java

public class EchoServerThreaded2 {

    public static final int ECHOPORT = 7;

    public static final int NUM_THREADS = 4;

    /** Metoda main — uruchamia serwery. */
    public static void main(String[] av) {
        new EchoServerThreaded2(ECHOPORT, NUM_THREADS);
    }

    /** Konstruktor. */
    public EchoServerThreaded2(int port, int numThreads) {
        ServerSocket servSock;

        try {
            servSock = new ServerSocket(port);

        } catch(IOException e) {
            /* Jeśli pojawią się błędy w operacjach wejścia-wyjścia,
             * zamykamy serwer. Stało się coś złego. */
            throw new RuntimeException("Nie można utworzyć obiektu ServerSocket " + e);
        }

        // Tworzymy grupę wątków i uruchamiamy je.
        for (int i=0; i<numThreads; i++) {
            new Handler(servSock, i).start();
        }
    }

    /** Klasa potomna klasy Thread obsługująca połączenia z serwerem. */
    class Handler extends Thread {
        ServerSocket servSock;
        int threadNumber;

        /** Tworzymy obiekt Handler. */
        Handler(ServerSocket s, int i) {
            servSock = s;
            threadNumber = i;
            setName("Wątek " + threadNumber);
        }
        public void run() {
            /* Czekamy na połączenie. Synchronizacja działania odbywa się
             * w oparciu o obiekt ServerSocket podczas wywoływania jego
             * metody accept(). */
            while (true) {
                try {
                    System.out.println(getName() + " oczekuje na połączenie");

                    Socket clientSocket;
                    // Czekamy na następne połączenie.
                    synchronized(servSock) {
                        clientSocket = servSock.accept();
                    }
                    System.out.println(getName() +
                          ", połączenie nawiązane, IP = " +
                          clientSocket.getInetAddress());
                    BufferedReader is = new BufferedReader(
                        new InputStreamReader(clientSocket.getInputStream()));
                    PrintStream os = new PrintStream(
                        clientSocket.getOutputStream(), true);
                    String line;
                    while ((line = is.readLine()) != null) {
                        os.print(line + "\r\n");
                        os.flush();
                    }
                    System.out.println(getName() + " połączenie zakończone ");
                    clientSocket.close();
                } catch (IOException ex) {
                    System.out.println(getName() +
                      ": Błąd wejścia-wyjścia podczas obsługi gniazda " + ex);
                    return;
                }
            }
        }
    }
}

Nic nie stoi na przeszkodzie, by podobny serwer zaimplementować przy użyciu NIO — „nowego” (przynajmniej w chwili gdy pojawiała się wersja J2SE 1.3) pakietu do obsługi operacji wejścia-wyjścia. Niemniej jednak kod takiego rozwiązania byłby dłuższy niż jakikolwiek przykład przedstawiony w tym rozdziale, a poza tym roiłoby się w nim od „Problem”. W internecie można znaleźć kilka dobrych poradników przeznaczonych dla osób, które muszą skorzystać z dodatkowej wydajności, jaką zapewnia NIO pod względem zarządzania połączeniami sieciowymi.

Nic nie stoi na przeszkodzie, by zaimplementować własny serwer HTTP do bardzo prostych zastosowań, co też zrobimy w tej recepturze. Do jakichkolwiek bardziej poważnych zastosowań należałoby użyć korporacyjnej wersji języka Java, zgodnie z informacjami zamieszczonymi na początku tego rozdziału.

Przykładowy serwer przedstawiony w tym rozdziale tworzy jedynie obiekt ServerSocket i zaczyna oczekiwać na nadchodzące połączenia. Kiedy jakieś połączenie zostanie odebrane, serwer odpowiada na nie, używając protokołu HTTP. Oznacza to, że obsługa żądań jest nieco bardziej złożona niż w serwerze Echo przedstawionym w „16.2. Zwracanie odpowiedzi (łańcucha znaków bądź danych binarnych)”. Niemniej jednak nie jest to pełna implementacja serwera; nazwy plików przesyłane w żądaniach są całkowicie ignorowane, a odpowiedź serwera jest zawsze taka sama. Oznacza to, że nasz serwer jest bardzo prosty; obsługuje jedynie minimalny podzbiór protokołu HTTP niezbędny do przesyłania odpowiedzi. Nieco bardziej złożony przykład serwera został przedstawiony w „22.11. Wielowątkowy serwer sieciowy”, po opisaniu zagadnień związanych ze stosowaniem wielu wątków. W razie konieczności zastosowania prawdziwego serwera WWW napisanego w Javie warto skorzystać z serwera Tomcat, który można pobrać z witryny WWW Fundacji Apache (http://tomcat.apache.org/). Jednak program przedstawiony na Przykład 16-8 w zupełności wystarcza, by zrozumieć, jak powinna wyglądać struktura kodu prostego serwera używającego konkretnego protokołu.

Przykład 16-8. /network/WebServer0.java

public class WebServer0 {
    public static final int HTTP = 80;
    public static final String CRLF = "\r\n";
    ServerSocket s;
    static final String VIEW_SOURCE_URL =
      "https://github.com/IanDarwin/javasrc/tree/master/src/main/java/network";

    /**
     * Metoda main(), jej działanie ogranicza się do utworzenia
     * serwera i wywołania jego metody runServer().
     */
    public static void main(String[] argv) throws Exception {
        System.out.println("DarwinSys JavaWeb Server 0.0, startuję...");
        WebServer0 w = new WebServer0();
        w.runServer(HTTP);     // Działanie serwera nigdy się nie kończy!!
    }

    /** Metoda zwraca faktyczny obiekt ServerSocket; przy czym jego
     * pobranie jest odroczone w czasie i następuje dopiero po
     * wykonaniu konstruktora, dzięki czemu klasy potomne będą mogły
     * modyfikować działanie ServerSocketFactory (na przykład
     * dodając korzystanie z SSL).
     * @param port Numer portu, na którym serwer będzie nasłuchiwał.
     */
    protected ServerSocket getServerSocket(int port) throws Exception {
        return new ServerSocket(port);
    }

    /** Metoda runServer akceptuje połączenia i każde z nich osobno
     * przekazuje do obiektu obsługującego - Handler. */
    public void runServer(int port) throws Exception {
        s = getServerSocket(port);
        while (true) {
            try {
                Socket us = s.accept();
                Handler(us);
            } catch(IOException e) {
                System.err.println(e);
                return;
            }

        }
    }

    /** Metoda Handler() obsługuje jedno połączenie z klientem.
     * To jedyne miejsce programu, które musi "znać" protokół HTTP.
     */
    public void Handler(Socket s) {
        BufferedReader is;     // Strumień wejściowy od klienta.
        PrintWriter os;        // Strumień wyjściowy do klienta.
        String request;        // Co klient nam przesłał.
        try {
            String from = s.getInetAddress().toString();
            System.out.println("Nawiązano połącznie z " + from);
            is = new BufferedReader(new InputStreamReader(s.getInputStream()));
            request = is.readLine();
            System.out.println("Żądanie: " + request);

            os = new PrintWriter(s.getOutputStream(), true);
            os.print("HTTP/1.0 200 To nadesłane dane:" + CRLF);
            os.print("Content-type: text/html" + CRLF);
            os.print("Server-name: DarwinSys NULL Java WebServer 0" + CRLF);
            String reply1 = "<html><head>" +
                "<title>Żądanie dotarło do złego serwera</title></head>\n" +
                "<h1>Witam, ";
            String reply2 = ", ale...</h1>\n" +
                "<p>Żądanie dotarło na normalny komputer, na którym  " +
                "nie działa serwer WWW z prawdziwego zdarzenia.\n" +
                "<p>Proszę wybrać inny adres!</p>\n" +
                "<p>Albo zajrzyj na stronę <a href=\"" +
                VIEW_SOURCE_URL + "\"> zawierającą kod źródłowy " +
                "serwera WebServer0, opublikowany na GitHubie</a>.</p>\n" +
                "<hr/><em>Serwer WebServer0 napisany w Javie</em><hr/>\n" +
                "</html>\n";
            os.print("Content-length: " +
                (reply1.length() + from.length() + reply2.length()) + CRLF);
            os.print(CRLF);
            os.print(reply1 + from + reply2 + CRLF);
            os.flush();
            s.close();
        } catch (IOException e) {
            System.out.println("Błąd wejścia-wyjścia: " + e);
        }
        return;
    }
}

JSSE udostępnia usługi na wielu poziomach, lecz najprostszym sposobem stosowania jej jest utworzenie obiektu ServerSocket za pomocą klasy SSLServerSocketFactory, a bezpośrednio — korzystając z konstruktora ServerSocket. SSL to skrót od słów Secure Socket Layer, a jego zmodyfikowana wersja jest znana jako TLS. Protokół SSL został zaprojektowany z myślą o internecie. W celu zabezpieczania innych protokołów konieczne jest zastosowanie innych rodzajów klasy SocketFactory.

Klasa SSLServerSocketFactory pozwala pobrać obiekt ServerSocket skonfigurowany do korzystania z szyfrowania SSL. Przykład przedstawiony na Przykład 16-9 korzysta z tej techniki, by przesłonić metodę getServerSocket() z Przykład 16-5. Jeśli ktoś pomyślał, że to zbyt łatwe, to pomylił się!

Faktycznie okazuje się, że to już cały kod, który należy napisać. Konieczne jest także skonfigurowanie certyfikatu SSL. Do celów demonstracyjnych może to być certyfikat przygotowany samodzielnie, zgodnie z procedurą opisaną w „21.12. Podpisywanie plików JAR” (kroki od 1. do 4.). Następnie należy poinformować warstwę JSSE, gdzie ma szukać magazynu kluczy; można to zrobić przy użyciu następującego polecenia:

java -Djavax.net.ssl.keyStore=/home/ian/.keystore -Djavax.net.ssl.
keyStorePassword=secrit JSSEWebServer0

Typowa przeglądarka nieco się zdziwi, widząc samodzielnie przygotowany certyfikat (patrz Rysunek 16-1), niemniej jeśli jest prawidłowy, to w końcu go zaakceptuje.

Rysunek 16-2 przedstawia odpowiedź serwera WebServer0 przesłaną przy użyciu protokołu HTTPS (co symbolizuje ikona kłódki wyświetlona w prawym dolnym rogu okna przeglądarki).

Uzyskiwanie komunikatów o błędach generowanych przez normalne aplikacje jest niemal we wszystkich systemach operacyjnych zadaniem bardzo łatwym. Jeśli jednak program, który chcemy testować, działa wewnątrz „kontenera”, takiego jak mechanizm obsługi serwletów lub serwer EJB, to uzyskanie dostępu do komunikatów o błędach generowanych przez ten program może być bardzo trudne, w szczególności jeśli sam kontener działa na zdalnym komputerze. W takich przypadkach możliwość wysyłania komunikatów przez testowany program i natychmiastowego wyświetlania ich w normalnej aplikacji działającej na lokalnym komputerze byłaby bardzo przydatna i wygodna. Nie trzeba mówić, że dzięki możliwościom obsługi gniazd dostępnym w języku Java implementacja takiego rozwiązania nie nastręcza kłopotów.

Istnieje wiele interfejsów programistycznych służących do rejestracji, które zapewniają takie możliwości:

  • Język Java od lat dysponuje interfejsem programistycznym do rejestracji komunikatów (opisałem go w „16.10. Rejestracja przez sieć przy użyciu pakietu java.util.logging”), który jest w stanie współpracować z wieloma mechanizmami do rejestracji komunikatów, w tym z uniksowym narzędziem syslog.

  • W ramach projektu Fundacji Apache — Logging Services Project — została opracowana biblioteka log4j, stosowana w wielu projektach o otwartym kodzie źródłowym wymagających rejestracji komunikatów.

  • Jakarta Commons Logging (JCL) Fundacji Apache. To API nie zostało opisane w książce, lecz jest podobne do pozostałych.

  • SLF4J (ang. Simple Logging Facade for Java, patrz „16.8. Rejestracja przez sieć przy użyciu SLF4J”) jest najnowszym rozwiązaniem, a przy tym — zgodnie z tym, co sugeruje jego nazwa — zostało ono stworzone zgodnie ze wzorcem projektowym Fasada i pozwala na korzystanie z innych mechanizmów rejestrujących.

  • A jeszcze zanim te wszystkie API zyskały popularność, napisałem także swoje własne, niewielkie rozwiązanie — bibliotekę netlog — zapewniające podobne możliwości. Nie opisałem jej w tej książce, gdyż zawsze preferowane jest zastosowanie jednego ze standardowych mechanizmów. Gdyby jednak ktoś chciał ją przywrócić do życia, to jej kod można znaleźć w przykładach dołączonych do książki w katalogu logging, jak również w internetowym repozytorium kodów javasrc.

Interfejsy do rejestracji komunikatów wchodzących w skład JDK, log4j oraz SFL4F, mają bardzo bogate możliwości i pozwalają na zapisywanie komunikatów w plikach, wyjściowych strumieniach OutputStream lub Writer, jak również na zdalnych serwerach log4j, syslog, a nawet w windowsowym mechanizmie EventLog.

Z punktu widzenia wykorzystania gniazd testowany program jest „klientem”, choć w rzeczywistości może on być wykonywany przez kontener taki jak serwer WWW lub serwer aplikacji. Wynika to z faktu, iż w odniesieniu do zagadnień sieciowych „klientem” jest program nawiązujący połączenie. Program działający na lokalnym komputerze i prezentujący przesyłane wiadomości jest z kolei „serwerem”, gdyż jego działanie sprowadza się do oczekiwania na połączenia i odbierania ich.

Jeśli chcemy korzystać z jakiejś sieciowej usługi rejestracji komunikatów, która będzie dostępna z sieci publicznej, to koniecznie należy sobie zdawać sprawę ze związanych z tym zagrożeń. Jedną z częstych form ataku jest prosty atak typu odmowa usługi (ang. Denial of Service, DoS), podczas którego napastnik stara się nawiązać wiele połączeń z serwerem, aby spowolnić jego działanie. Jeśli dziennik komunikatów jest zapisywany na przykład na dysku, to napastnik, wysyłając wiele bezsensownych danych, może doprowadzić do zapełnienia całego dysku. W najczęściej spotykanych rozwiązaniach serwer odbierający komunikaty i rejestrujący je jest umieszczany za zaporą sieciową, która blokuje dostęp do niego; jeśli jednak takie rozwiązanie nie jest możliwe, to należy pamiętać o zagrożeniu związanym z atakami typu DoS.

Skompilowanie kodu korzystającego z SLF4J wymaga tylko jednego pliku JAR: slf4j-api-1.x.y.jar (gdzie x i y to liczby, które mogą się zmieniać wraz z upływem czasu). Aby móc faktycznie zapisywać rejestrowane komunikaty, należy dodać do ścieżki jeden z kilku plików JAR z odpowiednimi implementacjami; najprostszym z nich jest slf4j-simple-1.x.y.jar (przy czym w obu plikach JAR liczby x i y powinny sobie odpowiadać).

Obiekt Logger można pobrać, wywołując metodę LoggerFactory.getLogger() i przekazując do niej nazwę klasy lub pakietu bądź też po prostu odwołanie do bieżącego obiektu Class. Potem można już wywoływać metody rejestrujące obiektu Logger. Najprostszy możliwy przykład rejestracji przedstawiłem na Przykład 16-10.

Dostępnych jest kilka metod służących do rejestracji informacji o różnych poziomach ważności. Przedstawiłem je w Tabela 16-1.

Jedną z największych zalet SLF4J w porównaniu z innymi API do rejestrowania komunikatów jest unikanie antywzorca „martwego łańcucha znaków”. W wielu innych API do rejestrowania komunikatów w kodzie można znaleźć instrukcje o następującej postaci:

logger.log("Uzyskaliśmy wartość " + object + "; to niezbyt dobrze...");

Takie rozwiązanie może prowadzić do problemów z wydajnością działania, gdyż jeszcze zanim mechanizm rejestrujący ustali, czy łańcuch zostanie zapisany, czy nie, dojdzie do jawnego wywołania metody toString() obiektu oraz wykonania dwóch operacji konkatenacji! Gdyby taka instrukcja była wykonywana wielokrotnie, mogłoby to oznaczać poważne obciążenie czasowe.

W innych pakietach do rejestracji komunikatów doprowadziło to do wykorzystania „strażników” — metod, które są w stanie bardzo szybko określić, czy rejestrowanie jest włączone, czy nie. Oznacza to, że kod rejestrujący komunikaty wygląda jak w poniższym przykładzie:

if (logger.isEnabled()) {
    logger.log("Uzyskaliśmy wartość " + object + "; to niezbyt dobrze...");
}

Takie rozwiązanie poprawia wydajność działania, lecz niepotrzebnie zaśmieca kod! W SLF4J zastosowano rozwiązanie polegające na wykorzystaniu mechanizmu podobnego (lecz nie identycznego) do metody MessageFormat dostępnej w JDK (patrz „15.10. Formatowanie komunikatów przy użyciu klasy MessageFormat”). Przykład jego użycia został przedstawiony na Przykład 16-11.

Choć powyższy przykład nie pokazuje rejestrowania komunikatów przez sieć na zdalnym komputerze, to jednak takie rozwiązanie można łatwo zaimplementować, korzystając z takich API jak log4j lub JUL, które zapewniają możliwość konfiguracji działania mechanizmu rejestracji. Biblioteka log4j została przedstawiona w następnej recepturze.

W witrynie SLF4J jest dostępny samouczek (http://www.slf4j.org/manual.html; napisany w języku angielskim), zawierający informacje dotyczące wykorzystania różnych plików JAR. Dostępne są także artefakty programu Maven (http://mvnrepository.com/artifact/org.slf4j) odpowiadające poszczególnym możliwym opcjom wykorzystania SLF4J.

Rejestrowanie komunikatów przy użyciu biblioteki log4j jest proste, wygodne i elastyczne. Obiekt Logger należy pobrać za pomocą statycznej metody Logger.getLogger(), przekazując do niej parametr konfiguracyjny, który może być bądź to nazwą hierarchiczną (taką jak com.darwinsys), bądź obiektem Class (na przykład MyApp.class), który pozwoli określić pełną nazwę pakietu i klasy. Tej nazwy można użyć w pliku konfiguracyjnym, by określić poziom szczegółowości, na jakim ma działać obiekt rejestrujący. Obiekty klasy Logger udostępniają publiczne metody (debug(), info(), warn(), error() oraz fatal()), do których można przekazać obiekt, jaki ma zostać zarejestrowany (wraz z opcjonalnym obiektem typu Throwable). Podobnie jak w przypadku metody System.out.printl(), jeśli w wywołaniu którejkolwiek z tych metod zostanie przekazany obiekt inny niż String, to zostanie wywołana jego metoda toString(). Dostępna jest także ogólna metoda służąca do rejestracji o następującej postaci:

public void log(Level level, Object message);

Klasa Level została zdefiniowana w pakiecie log4j. Standardowe poziomy ważności komunikatów zostały uporządkowane w następującej kolejności: DEBUG < INFO < WAR < ERROR < FATAL. Oznacza to, że komunikaty związane z testowaniem są najmniej ważne, natomiast najważniejsze są błędy krytyczne. Z każdym obiektem Logger jest skojarzony pewien poziom ważności; komunikaty, których poziom jest mniejszy od poziomu używanego obiektu, są po cichu ignorowane.

Prosta aplikacja może rejestrować komunikaty, używając kilku przedstawionych poniżej instrukcji:

/logging/Log4JDemo.java

public class Log4JDemo {
    public static void main(String[] args) {

        Logger myLogger = Logger.getLogger("com.darwinsys");

        // PropertyConfigurator.configure("log4j.properties");

        Object o = new Object();
        myLogger.info("Utworzyłem obiekt: " + o);

    }
}

Jeśli powyższy program zostanie skompilowany i uruchomiony bez wcześniejszego przygotowania pliku właściwości, log4j.properties, to wyświetli on stosowne ostrzeżenie, a żadne komunikaty nie będą rejestrowane:

ant run.log4jdemo
Buildfile: build.xml
run.log4jdemo:
     [java] log4j:WARN No appenders could be found for logger (com.darwinsys).
     [java] log4j:WARN Please initialize the log4j system properly.

Dlatego też konieczne jest utworzenie pliku właściwości, którego domyślną nazwą będzie log4j.properties. Nazwę tego pliku można także określić przy użyciu właściwości systemowych: -Dlog4j.configuration = URL.

Każdy obiekt Logger dysponuje wartością typu Level określającą poziom ważności rejestrowanych komunikatów oraz obiektem Appender odpowiadającym za ich zapisywanie. Na przykład obiekt ConsoleAppender zapisuje komunikaty w strumieniu System.out; dostępne są także inne klasy pozwalające na zapisywanie komunikatów w plikach, w systemowych mechanizmach rejestrujących i tak dalej. Poniżej przedstawiłem przykładową postać pliku konfiguracyjnego:

# Ustawia poziom glownego obiektu rejestrujacego na DEBUG
# i okresla, ze za zapis bedzie odpowiadal obiekt APP1.
log4j.rootLogger=DEBUG, APP1

# APP1 bedzie obiektem typu ConsoleAppender.
log4j.appender.APP1=org.apache.log4j.ConsoleAppender

# APP1 uzywa ukladu typu PatternLayout.
log4j.appender.APP1.layout=org.apache.log4j.PatternLayout
log4j.appender.APP1.layout.ConversionPattern=%-4r [%t] %-5p %c %x - %m%n

Powyższy plik konfiguracyjny określa, że główny obiekt rejestrujący będzie operował na poziomie ważności DEBUG — czyli że będzie zapisywał wszystkie komunikaty — a obiektem odpowiadającym za zapis będzie APP1, skonfigurowany w dalszej części pliku. Warto zwrócić uwagę, że w powyższym pliku nie musiałem odwoływać się do obiektu rejestrującego dla pakietu com.darwinsys, gdyż każdy obiekt Logger dziedziczy po głównym obiekcie rejestrującym; dlatego w prostych aplikacjach wystarczy skonfigurować wyłącznie ten obiekt. Plik właściwości można także zapisać w formie dokumentu XML, a nawet można napisać własny analizator właściwości (choć niemal nikt tego nie robi). Kiedy powyższy plik konfiguracyjny zostanie umieszczony we właściwym miejscu, efekty uruchomienia programu będą znacznie lepsze:

$ ant run.log4jdemo
Buildfile: build.xml

init:

build:

run.log4jdemo:
     [java] 1 [main] INFO com.darwinsys - Utworzyłem obiekt: java.lang.
Object@bb6086

BUILD SUCCESSFUL
Total time: 1 second

Bardziej typowym zastosowaniem mechanizmu rejestracji komunikatów mogłoby być przechwytywanie i zapisywanie zgłaszanych wyjątków. Przykład takiego programu przedstawia Przykład 16-12.

Znaczna część elastyczności biblioteki log4j wynika właśnie z faktu, że korzysta ona z zewnętrznych plików konfiguracyjnych — dzięki nim można na przykład włączać i wyłączać rejestrowanie komunikatów bez konieczności rekompilacji aplikacji. Plik właściwości, który niemal w całości wyłącza rejestrowanie komunikatów, może mieć następującą postać:

log4j.rootLogger=FATAL, APP1

W tym przypadku rejestrowane będą wyłącznie błędy krytyczne, a komunikaty o niższych poziomach ważności będą ignorowane.

W celu rejestrowania komunikatów z klienta na zdalnym komputerze należy użyć pakietu org.apache.log4j.net, który zawiera kilka klas typu Appender, oraz serwerów, do których można je podłączyć.

Więcej informacji na temat biblioteki log4j można znaleźć w jej witrynie WWW (http://logging.apache.org/log4j/2.x/). Warto wspomnieć, że jest to oprogramowanie dostępne bezpłatnie, rozpowszechniane na zasadach licencji Apache Software Foundation.

Dostępny w standardowym JDK interfejs programistyczny do rejestracji komunikatów — Java Logging API (w pakiecie java.util.logging) — jest bardzo podobny do biblioteki log4j i w widoczny sposób był na niej wzorowany. Obiekt Logger pobiera się, wywołując statyczną metodę Logger.getLogger() i podając w jej wywołaniu opisowy łańcuch znaków. Komunikaty zapisuje się przy użyciu metod instancyjnych tego obiektu; poniżej przedstawiłem niektóre z tych metod:

public void log(java.util.logging.LogRecord);
public void log(java.util.logging.Level,String);
// i kilka innych przesłoniętych metod log( )
public void logp(java.util.logging.Level,String,String,String);
public void logrb(java.util.logging.Level,String,String,String,String);

// Metody pomocnicze używane do śledzenia przebiegu realizacji programu
public void entering(String,String);
public void entering(String,String,Object);
public void entering(String,String,Object[]);
public void exiting(String,String);
public void exiting(String,String,Object);
public void throwing(String,String,Throwable);

// Metody pomocnicze zastępujące metodę log( ) i rejestrujące
// komunikaty o określonym poziomie ważności.
public void severe(String);
public void warning(String);
public void info(String);
public void config(String);
public void fine(String);
public void finer(String);
public void finest(String);

Podobnie jak w przypadku biblioteki log4j, także i tutaj z każdym obiektem Logger skojarzony jest pewien poziom ważności, a komunikaty na niższym poziomie są po cichu ignorowane:

public void setLevel(java.util.logging.Level);
public java.util.logging.Level getLevel( );
public boolean isLoggable(java.util.logging.Level);

I podobnie jak w przypadku biblioteki log4j, także i w tym API za zapis komunikatu odpowiadają odrębne obiekty. Z każdym obiektem Logger skojarzony jest obiekt Handler:

public synchronized void addHandler(java.util.logging.Handler);
public synchronized void removeHandler(java.util.logging.Handler);
public synchronized java.util.logging.Handler[] getHandlers( );

Z kolei każdy obiekt Handler posiada obiekt Formatter, który formatuje zawartość obiektu LogRecord do odpowiedniej wyjściowej postaci. Tworząc własne obiekty Formatter, można uzyskać dokładniejszą kontrolę nad postacią zapisywanych informacji.

Jednak w odróżnieniu od biblioteki log4j mechanizm rejestracji dostępny w standardowej wersji języka Java ma swoją własną domyślną konfigurację. Dzięki niej najprostsza postać programu korzystającego z mechanizmu rejestracji wygląda tak jak na Przykład 16-13.

Wykonanie tego programu spowoduje wyświetlenie następujących wyników:

C:> java logging.Log14Demo
Mar 8, 2014 7:48:26 PM Log14Demo main
INFO: Utworzyłem obiekt: java.lang.Object@57f0dc
C:>

Podobnie jak w przypadku biblioteki log4j, także ten mechanizm rejestracji jest domyślnie używany do zapisywania komunikatów o zgłaszanych wyjątkach; przykład takiego rozwiązania przedstawiłem na Przykład 16-14.

Każdy komputer w sieci posiada jeden lub kilka „interfejsów sieciowych”. Na typowych komputerach stacjonarnych ten interfejs sieciowy reprezentuje kartę sieciową, port sieciowy lub programowy interfejs sieciowy jakiegoś typu, na przykład interfejs pętli zwrotnej (ang. loopback). Każdy taki interfejs posiada swoją nazwę, zdefiniowaną przez system operacyjny. W większości systemów uniksowych urządzenia te mają dwu- lub trzyliterową nazwę pochodzącą od nazwy sterownika, z dołączoną do nich cyfrą (zaczynając od 0); na przykład: eth0 lub en0 dla pierwszej karty Ethernet w systemie (w przypadku gdy karta nie udostępnia informacji o producencie) lub de0 i de1 dla pierwszych dwóch kart sieciowych Ethernet bazujących na kartach Digital Equipment[67] DC21x4x, xl0 dla kart 3Com EtherLink XL i tak dalej. Praktycznie na wszystkich platformach uniksowych interfejs pętli zwrotnej jest niezmiennie oznaczany jako lo0.

Ale co z tego? W większości przypadków nie ma to dla nas żadnego znaczenia. Jeśli dysponujemy tylko jednym połączeniem sieciowym, na przykład połączeniem kablowym z naszym dostawcą usług internetowych, to tak naprawdę nic więcej nas nie obchodzi. Natomiast ma to znaczenie na serwerach, gdy okazuje się, że konieczne jest określenie na przykład nazwy danej sieci. Dostęp do tych informacji zapewnia klasa NetworkInterface. Udostępnia ona statyczne metody do wyświetlenia listy interfejsów sieciowych oraz inne do określania adresu konkretnego interfejsu. Program przedstawiony na Przykład 16-15 pokazuje kilka przykładów zastosowania tej klasy. Po uruchomieniu program wyświetla nazwy wszystkich lokalnych interfejsów sieciowych. Jeśli przez jakiś przypadek Czytelnik będzie używał komputera o nazwie laptop, to program ten wyświetli jego adres sieciowy; we wszystkich innych przypadkach należałoby zapewne zmienić program tak, by pobierał nazwę lokalnego komputera z wiersza poleceń; jednak implementację takiego rozwiązania zostawiłem jako ćwiczenie dla Czytelników.

Program przedstawiony w tej recepturze stanowi prosty serwer pogawędek (patrz Przykład 16-16), współpracujący z klientem pogawędek opisanym w „13.13. Program — klient pogawędek internetowych”. Serwer przyjmuje połączenia od dowolnej liczby klientów. Wiadomość nadesłana przez dowolnego klienta jest przekazywana do wszystkich klientów. Serwer demonstruje sposoby wykorzystania obiektu ServerSocket oraz wątków (omówione dokładniej w Rozdział 22.). Poza tym ze względu na wzajemną interakcję pomiędzy poszczególnymi klientami serwer musi przechowywać dane o wszystkich klientach, z jakimi zostały nawiązane połączenia. W tym programie do przechowywania zmiennej ilości informacji wykorzystuję obiekt ArrayList (patrz „7.4. Klasa podobna do tablicy, lecz bardziej dynamiczna”); nie zapomniałem także o wykorzystaniu instrukcji synchronized — umieściłem wewnątrz niej wszystkie operacje na liście, dzięki czemu nie jest możliwa sytuacja, w której jeden wątek odczytuje dane z listy, podczas gdy drugi modyfikuje jej zawartość (także te zagadnienia zostały omówione w Rozdział 22.).

Przykład 16-16. ChatServer.java

public class ChatServer {
    /** Nazwa serwera w komunikatach */
    protected final static String CHATMASTER_ID = "ChatMaster";
    /** Separator pomiędzy nazwą i komunikatem */
    protected final static String SEP = ": ";
    /** Gniazdo serwera */
    protected ServerSocket servSock;
    /** Lista podłączonych klientów */
    protected List<ChatHandler> clients;
    /** Flaga testowania */
    private static boolean DEBUG = false;

    /** Metoda main tworzy obiekt ChatServer i uruchamia go, a działanie
     * metody runServer nie powinno się nigdy zakończyć. */
    public static void main(String[] argv) throws IOException {
        System.out.println("DarwinSys ChatServer 0.1 uruchamianie...");
        if (argv.length == 1 && argv[0].equals("-debug"))
            DEBUG = true;
        ChatServer w = new ChatServer();
        w.runServer();            // Ta metoda nigdy nie powinna się zakończyć.
        System.out.println("**BŁĄD* Chat Server 0.1 zamykanie serwera...");
    }

    /** Tworzymy i uruchamiamy usługę
     * @throws IOException
     */
    ChatServer() throws IOException {
        clients = new ArrayList<>();

        servSock = new ServerSocket(ChatProtocol.PORTNUM);
        System.out.println("DarwinSys Chat Server działa na porcie " +
                ChatProtocol.PORTNUM);
    }

    public void runServer() {
        try {
            while (true) {
                Socket userSocket = servSock.accept();
                String hostName = userSocket.getInetAddress().getHostName();
                System.out.println("Odebrano połączenie z " + hostName);
                ChatHandler cl = new ChatHandler(userSocket, hostName);
                String welcomeMessage;
                synchronized (clients) {
                    clients.add(cl);
                    if (clients.size() == 1) {
                        welcomeMessage = "Witam! Jesteś tu pierwszy";
                    } else {
                        welcomeMessage = "Witam! Jesteś ostatnim z " +
                                clients.size() + " użytkowników.";
                    }
                }
                cl.start();
                cl.send(CHATMASTER_ID, welcomeMessage);
            }
        } catch(IOException e) {
            log("Błąd wejścia-wyjścia w metodzie runServer: " + e);
        }
    }

    protected void log(String s) {
        System.out.println(s);
    }

    /**
     * Dalsza część tego pliku zawiera klasę wewnętrzną, odrębny
     * obiekt tej klasy jest tworzony w ramach obsługi każdego
     * połączenia.
     */
    protected class ChatHandler extends Thread {
        /** Gniazdo klienta */
        protected Socket clientSock;
        /** Obiekt BufferedReader służący do odczytu danych z gniazda */
        protected BufferedReader is;
        /** Obiekt PrintWriter służący do zapisu komunikatów w gnieździe */
        protected PrintWriter pw;
        /** Komputer, na którym działa klient */
        protected String clientIP;
        /** Nazwa */
        protected String login;

        /* Tworzymy obiekt */
        public ChatHandler(Socket sock, String clnt) throws IOException {
            clientSock = sock;
            clientIP = clnt;
            is = new BufferedReader(
                new InputStreamReader(sock.getInputStream()));
            pw = new PrintWriter(sock.getOutputStream(), true);
        }

        /** Każdy ChatHandler jest wątkiem (Thread), a zatem musi mieć
         * metodę run(), która obsługuje to połączenie.
         */
        public void run() {
            String line;
            try {
                /* W tej pętli musimy pozostawać tak długo, jak klient
                 * jest podłączony, a zatem kiedy pętla zostanie
                 * zakończona, należy także zakończyć połączenie
                 * z klientem.
                 */
                while ((line = is.readLine()) != null) {
                    char c = line.charAt(0);
                    line = line.substring(1);
                    switch (c) {
                    case ChatProtocol.CMD_LOGIN:
                        if (!ChatProtocol.isValidLoginName(line)) {
                            send(CHATMASTER_ID, "NAZWA " + line +
                                                         " jest niewłaściwa");
                            log("Niewłaściwa NAZWA z " + clientIP);
                            continue;
                        }
                        login = line;
                        broadcast(CHATMASTER_ID, login +
                            " dołączył, aktualnie jest " +
                            clients.size() + " użytkowników");
                        break;
                    case ChatProtocol.CMD_MESG:
                        if (login == null) {
                            send(CHATMASTER_ID,
                                 "proszę się najpierw zalogować");
                            continue;
                        }
                        int where = line.indexOf(ChatProtocol.SEPARATOR);
                        String recip = line.substring(0, where);
                        String mesg = line.substring(where+1);
                        log("WIAD.: " + login + "-->" + recip + ": "+ mesg);
                        ChatHandler cl = lookup(recip);
                        if (cl == null)
                            psend(CHATMASTER_ID, recip +
                                                  " nie jest zalogowany.");
                        else
                            cl.psend(login, mesg);
                        break;
                    case ChatProtocol.CMD_QUIT:
                        broadcast(CHATMASTER_ID,
                            "Do widzenia " + login + "@" + clientIP);
                        close();
                        return;        // Koniec działania.

                    case ChatProtocol.CMD_BCAST:
                        if (login != null)
                            broadcast(login, line);
                        else
                            log("B<L OD " + clientIP);
                        break;
                    default:
                        log("Nieznane polecenie " + c + " z " + login +
                            "@" + clientIP);
                    }
                }
            } catch (IOException e) {
                log("Błąd wejścia-wyjścia: " + e);
            } finally {
                // Połączenie zamknięte, zatem już kończmy.
                System.out.println(login + SEP + "Wszystko zrobione.");
                String message =
                               "Ten komunikat nie powinien się już pokazać.";
                synchronized(clients) {
                    clients.remove(this);
                    if (clients.size() == 0) {
                        System.out.println(CHATMASTER_ID + SEP +
                            "Jestem taki samotny, że chyba się rozpłaczę...");
                    } else if (clients.size() == 1) {
                        message =
                            "Hej, znowu rozmawiasz sam ze sobą";
                    } else {
                        message =
                            "Aktualnie jest podłączonych " +
                            clients.size() + " użytkowników";
                    }
                }
                broadcast(CHATMASTER_ID, message);
            }
        }

        protected void close() {
            if (clientSock == null) {
                log("zamykamy nieotworzone połączenie");
                return;
            }
            try {
                clientSock.close();
                clientSock = null;
            } catch (IOException e) {
                log("Błąd podczas zamykania połączenia z " + clientIP);
            }
        }

        /** Wysłanie jednego komunikatu do użytkownika */
        public void send(String sender, String mesg) {
            pw.println(sender + SEP + mesg);
        }

        /** Wysłanie komunikatu prywatnego */
        protected void psend(String sender, String msg) {
            send("<*" + sender + "*>", msg);
        }

        /** Wysłanie jednego komunikatu do wszystkich użytkowników */
        public void broadcast(String sender, String mesg) {
            System.out.println("Transmisja ogólna " + sender + SEP + mesg);
            clients.forEach(sib -> {
                if (DEBUG)
                    System.out.println("Wysłanie do " + sib);
                sib.send(sender, mesg);
            });
            if (DEBUG) System.out.println("Transmisja wykonana");
        }

        protected ChatHandler lookup(String nick) {
            synchronized (clients) {
                for (ChatHandler cl : clients) {
                    if (cl.login.equals(nick))
                        return cl;
                }
            }
            return null;
        }

        /** Zapisanie obiektu w formie łańcucha znaków. */
        public String toString() {
            return "ChatHandler[" + login + "]";
        }
    }
}

Używałem tego serwera, jednocześnie nawiązując z nim połączenia z kilku klientów — program działał bez zarzutów.

Dobrą ogólną pozycją poświęconą zagadnieniom opisanym w tym rozdziale jest książka Java Network Programming wydana przez wydawnictwo O’Reilly (http://shop.oreilly.com/product/0636920028420.do).

Serwery wszystkich mechanizmów sieciowych są niezwykle wrażliwe na wszelkie zagrożenia bezpieczeństwa. Jeden błędnie skonfigurowany lub niewłaściwie napisany serwer może zagrozić bezpieczeństwu całej sieci! Istnieje wiele książek poświęconych zagadnieniom bezpieczeństwa sieci, jednak dwie z nich zasługują na szczególną uwagę. Są to Firewalls and Internet Security Williama Cheswicka i Stevena Bellovina oraz seria książek ze słowami Hacking exposed w tytułach, z których autorami pierwszej są Stuart McClure, Joel Scambray oraz George Kurtz (wydało ją wydawnictwo McGraw-Hill).

W ten sposób zakończyłem prezentację zagadnień związanych z pisaniem w języku Java programów działających jako serwery i wykorzystujących gniazda. Przedstawiony na samym końcu serwer pogawędek można by zaimplementować, używając kilku innych technologii, takich jak RMI (ang. Remote Methods Invocation), usługi internetowe, JMS (ang. Java Message Service), interfejs programistyczny używany w rozwiązaniach korporacyjnych pozwalający na składowanie i przekazywanie komunikatów. Zagadnienia związane z tą technologią wykraczają poza zakres tematyczny niniejszej książki, lecz przykład serwera pogawędek działającego właśnie w taki sposób został przedstawiony w książce Java Message Service (http://shop.oreilly.com/product/9780596522056.do) wydanej przez wydawnictwo O’Reilly.



[65] Oczywiście, tworząc własny serwer, nie można wybrać pierwszego lepszego numeru portu. Porty wykorzystywane przez wiele popularnych usług, na przykład port 22 używany przez SSH lub 25 używany przez protokół SMTP, zostały podane w pliku systemowym o nazwie services. Dodatkowo w systemach operacyjnych stosowanych w serwerach wszystkie porty o numerach mniejszych od 1024 są uważane za porty „uprzywilejowane”, a ich utworzenie wymaga uprawnień administratora lub użytkownika root. Rozwiązanie to stanowiło jeden z wczesnych mechanizmów zabezpieczających; dziś, gdy do internetu podłączone są miliony komputerów biurowych, nie ma ono większego sensu, niemniej wciąż obowiązuje.

[66] Istnieją pewne ograniczenia limitujące liczbę wątków, jakie można utworzyć, jednak mają one wpływ wyłącznie na bardzo duże serwery obsługujące całe korporacje. Nie można oczekiwać, że standardowe środowisko wykonawcze Javy będzie w stanie obsługiwać tysiące jednocześnie działających wątków. W przypadku tworzenia dużych serwerów, które muszą osiągać bardzo wysoką efektywność działania, można się odwoływać do rodzimego kodu systemowego (patrz „24.6. Dołączanie kodu rodzimego”) przy wykorzystaniu funkcji select() oraz poll().

[67] Firma Digital Equipment została wchłonięta przez firmę Compaq, która następnie została wchłonięta przez firmę HP (jednak nazwa pozostała ta sama); de, gdyż inżynierowie określający nazwy urządzeń nie zwracają uwagi na takie detale jak przejęcia firm.