[Java][Kryonet] Połączenie TCP i UDP. Serwer i Client.

W tym poście przedstawię wam podstawowe zasady programowania socketów w Javie. Ustanowimy podstawowe połączenie pomiędzy serwerem a klientem.
Na początku zaczynamy oczywiście od utworzenia nowego mavenowego projektu. I stworzeniu klasy Main. Dodatkowo tworzymy dwie klasy o nazwach ClientThread i SerwerThread. Które będę reprezentowały sobą odpowiednie strony połączenia. Niech każda z nich implementuje interfejs Runnable.

Oprócz tych dwóch klas systemowych, utworzymy dwa obiekty modelu, które będą przesyłane pomiędzy serwerem a klientem. Utwórzmy więc dwie klasy Ping i Pong. Cały projekt powinien wyglądać tak:



Nasz plik pom.xml powinien zawierać w sobie dependency z kryonetem:

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>KryoProject</groupId>
    <artifactId>KryoProject</artifactId>
    <version>0.0.1-SNAPSHOT</version>

    <dependencies>
        <!-- KryoNet -->
        <dependency>
            <groupId>com.esotericsoftware</groupId>
            <artifactId>kryonet</artifactId>
            <version>2.22.0-RC1</version>
        </dependency>
    </dependencies>
    
    <build>
        <sourceDirectory>src</sourceDirectory>
        <plugins>
            <plugin>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.1</version>
                <configuration>
                    <source>1.8</source>
                    <target>1.8</target>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>


Klasa Main natomiast powinna wyglądać tak:

package pl.silver.kryo;

public class Main {

    public static void main(String[] args) {
        new Main().run();
    }

    private void run() {
        ServerThread server = new ServerThread();
        Thread threadServer = new Thread(server);
        threadServer.setName("Server");
        threadServer.start();
        
        Thread threadClient = new Thread(new ClientThread());
        threadClient.setName("Client");
        threadClient.setDaemon(true);
        while(!server.isReady()){
            Thread.yield();
        }
        threadClient.start();
    }
}

Jest to klasyczny przykład odpalenia Serwera i Klienta w tej samej apliakcji. Żeby tego dokonać potrzebujemy dwóch osobnych wątków. Tworzymy je, najpierw wątek z serwerem który musi się uruchomić najpierw. Następnie wątek główny Main czeka aż Serwer będzie gotowy do pracy poprzez pętle while() a na końcu uruchamia klienta, który może się połączyć z już przygotowanym do obsługi klientów serwerem.

ServerThread:

package pl.silver.kryo;

import java.io.IOException;
import java.util.concurrent.atomic.AtomicBoolean;

import com.esotericsoftware.kryonet.Connection;
import com.esotericsoftware.kryonet.JsonSerialization;
import com.esotericsoftware.kryonet.Listener;
import com.esotericsoftware.kryonet.Server;

public class ServerThread implements Runnable  {
    public final static int WRITE_BUFFER = 256 * 1024;
    public final static int READ_BUFFER = 256 * 1024;
    public final static int PORT_TCP = 56555;
    public final static int PORT_UDP = 56777;

    private AtomicBoolean ready = new AtomicBoolean(false);
    private Server server;
    private Listener listener;
    
    @Override
    public void run() {
        
        listener = new ServerListener();        
        server = new Server(WRITE_BUFFER,READ_BUFFER,new JsonSerialization());
        server.addListener(listener);
        server.start();
        try {
            server.bind(PORT_TCP, PORT_UDP);
            ready.set(true);           
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    public boolean isReady() {
        return ready.get();
    }
    
    class ServerListener extends Listener{
        @Override
        public void received(Connection connection, Object object) {
            System.out.println("Otrzymano od: " + connection.getID() + " obiekt: " + object);
            connection.sendTCP(new Pong());
        }
    }
}

Tutaj mamy logikę strony serwera. W odróżnieniu od klasycznego sposobu działania socketów IO, kryonet działa na NIO(nieblokujących się socketach), ale w prosty sposób ukrywa przed nami złożoność tego procesu. Inaczej mówiąc w klasycznym przykładzie w logice byłaby pętla while która nasłuchiwałaby wiadomości od klientów. Tutaj w prosty sposób pomijamy ten niskopoziomowy i   sposób na rzecz systemu zdarzeniowego. System zdarzeniowy pozwala tylko na reagowanie na zachodzące zdarzenia, jak odebranie wiadomości od klienta czy od serwera, i nie zmusza nas do obsługi i nasłuchiwania nadejścia takiego zdarzenia.

Ale po kolei:

    public final static int WRITE_BUFFER = 256 * 1024;
    public final static int READ_BUFFER = 256 * 1024;
    public final static int PORT_TCP = 56555;
    public final static int PORT_UDP = 56777;


W tych linijkach definiujemy takie parametry jak wielkość buffora w bajtach. Im większa wartość tym więcej danych można upchnąć w buforze do wysłania, ale też wzrasta pobyt na pamięć operacyjną i mogą też wystąpić inne komplikacje.

W dwóch kolejnych linijek standardowo określamy port na jakim będzie nasłuchiwał server na protokole TCP i UDP.
Jeśli nie wiemy jaka jest różnica między TCP a UDP to w skrócie wygląda ona tak że dane wysłane przez TCP są pewne. Tzn. Jeśli wyślemy obiekt Ping przez TCP to mamy pewność że jeśli tylko istnieje połączenie sieciowe z serwerem to nasz klient dostarczy ten obiekt lub zwróci nam błąd. Natomiast przy UDP nie mamy tej pewności, jeśli nasz obiekt gdzieś po drodze się zawieruszy to ani ty ani serwer nic o tym nie będzie wiedział. Oczywiście zaletą wysyłania danych po UDP jest szybkość ich wysyłania i mniejsze wykorzystanie łącza.

W tym przykładzie będziemy korzystać z protokołu TCP, ponieważ zależy nam na tym aby wysyłanie danych było nadzorowane.

private AtomicBoolean ready = new AtomicBoolean(false);

AtomicBoolean jest obiektem przystosowanym do pracy w środowisku wielowątkowym. Jest to opakowany typ prymitywny boolean. Posłuży nam on do powiadomienia wątka Main o tym że klient może być już zainicjializowany, ponieważ serwer jest już gotowy do pracy.

 private Server server;
 private Listener listener;

Tutaj mamy dwa obiekty KryoNet, Server który zarządza transmisją w sieci, oraz Listener który jest wstrzykiwany do serwera i który odbiera zdarzenia przychodzące z sieci. Jak np. otrzymanie danych od klienta.

    @Override
    public void run() {
        
        listener = new ServerListener();        
        server = new Server(WRITE_BUFFER,READ_BUFFER,new JsonSerialization());
        server.addListener(listener);
        server.start();
        try {
            server.bind(PORT_TCP, PORT_UDP);
            ready.set(true);
            System.out.println("Ready set to true : " + isReady());
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

Jako że nasz ServerThread jest osobnym wątkiem, jego główna logika jest umieszczona w nadpisanej metodzie run z interfejsu Runnable. Kod ten będzie wykonany równolegle do głównego wątku Main.
Jak widzimy najpierw inicjializujemy naszego listenera:

listener = new ServerListener();        

Będzie to nasza główna logika serwerowa. Następnie tworzymy obiekt serwera:

 server = new Server(WRITE_BUFFER,READ_BUFFER,new JsonSerialization());

Tutaj definiujemy rozmiar jego bufforów, a także ustawiamy serializera. W tym kursie zdecydowałem się na użycie JsonSerialization ponieważ zwalnia ono z kilku dodatkowych rzeczy do konfiguracji, kosztem większej ilości danych jakie wysyła. Dane wysyłane przez tego serializera mają postać JSON`a co umożliwia nam także śledzenie ich wartości w konsoli naszej aplikacji.

        server.addListener(listener);
        server.start();     

Dalej wstrzykujemy naszego listenera i starujemy serwer. Musimy pamiętać że przed rozpoczęciem nasłuchiwania na portach, musimy mieć wystartowany serwer albo klient, inaczej dostaniemy wyjątek:

try {
            server.bind(PORT_TCP, PORT_UDP);
            ready.set(true);           
        } catch (IOException e) {
            e.printStackTrace();
        }

Tutaj podczepiamy serwer pod nasze porty nasłuchowe i wskazujemy że nasz serwer jest gotowy, zmieniając wartość zmiennej ready na true.

To tyle jeśli chodzi o rozpoczęcie pracy serwera. Oczywiście jest to podstawowa konfiguracja a biblioteka Kryonet umożliwia znacznie więcej opcji konfiguracyjnych niż to co widzimy.

Logika naszego serwera jest natomiast zapisana tutaj:

    class ServerListener extends Listener{
        @Override
        public void received(Connection connection, Object object) {
            System.out.println("Otrzymano od: " + connection.getID() + " obiekt: " + object);
            connection.sendTCP(new Pong());
        }
    }

Polega ona na odbieraniu obiektów od klienta, wyświetlaniu id clienta, oraz obiektu który przysłał a następnie odesłaniu mu obiektów Pong() jako potwierdzenia odebrania danych.
Przenosząc nasz listener do osobnej klasy, możemy rozwinąć logikę naszego serwera bardziej, tworząc z niego chat, czy tez serwer do gier.

ClientThread:

package pl.silver.kryo;

import java.io.IOException;
import java.util.Timer;
import java.util.TimerTask;

import com.esotericsoftware.kryonet.Client;
import com.esotericsoftware.kryonet.Connection;
import com.esotericsoftware.kryonet.JsonSerialization;
import com.esotericsoftware.kryonet.Listener;

public class ClientThread implements Runnable {
    public final static String HOST = "127.0.0.1";
    public final static int TIMEOUT = 1000;
    public final static int WRITE_BUFFER = 256 * 1024;
    public final static int READ_BUFFER = 256 * 1024;
    public final static int PORT_TCP = 56555;
    public final static int PORT_UDP = 56777;

    private Client client;
    private Listener listener;

    @Override
    public void run() {
        listener = new ClientListener();

        client = new Client(WRITE_BUFFER, READ_BUFFER, new JsonSerialization());
        client.start();
        client.addListener(listener);
        try {
            client.connect(TIMEOUT, HOST, PORT_TCP, PORT_UDP);
        } catch (IOException ex) {
            ex.printStackTrace();
        }

        Timer timer = new Timer();
        timer.schedule(new TimerTask() {
            @Override
            public void run() {
                client.sendTCP(new Ping());
            }
        }, 1000, 1000);
    }

    class ClientListener extends Listener {
        @Override
        public void received(Connection connection, Object object) {
            System.out.println("Odpowiedź od serwera: " + object);
        }
    }
}


Tutaj sposób inicjializacji wygląda bardzo podobnie jak na serwerze. Z tym że musimy zamienić czynność bindowania na łączenie z serwerem. Zacznijmy od parametrów:

    public final static String HOST = "127.0.0.1";
    public final static int TIMEOUT = 1000;
    public final static int WRITE_BUFFER = 256 * 1024;
    public final static int READ_BUFFER = 256 * 1024;
    public final static int PORT_TCP = 56555;
    public final static int PORT_UDP = 56777;

Parametr HOST musi określać adres sieciowy naszego serwera. Wartość którą tu widzimy "127.0.0.1", zawsze odnosi się to tego samego urządzenia z którego łączy się klient.
TIMEOUT służy natomiast do określenia czasu oczekiwania na nawiązanie połączenia z serwerem, podanym w ms.

public final static int WRITE_BUFFER = 256 * 1024;
public final static int READ_BUFFER = 256 * 1024;

Wielkość bufforów może być mniejsza niz w serwerze, ponieważ to serwer zazwyczaj umieszcza więcej danych u siebie, które następnie wysyła do klientów. Ale w tym przypadku możemy ustawić taką samą wartość jak na serwerze.

    public final static int PORT_TCP = 56555;
    public final static int PORT_UDP = 56777;

Parametry określające porty muszą być identyczne jak w serwerze, inaczej połączenie nie zadziała.

 private Client client;
    private Listener listener;

    @Override
    public void run() {
        listener = new ClientListener();

        client = new Client(WRITE_BUFFER, READ_BUFFER, new JsonSerialization());
        client.start();
        client.addListener(listener);
        try {
            client.connect(TIMEOUT, HOST, PORT_TCP, PORT_UDP);
        } catch (IOException ex) {
            ex.printStackTrace();
        }

        Timer timer = new Timer();
        timer.schedule(new TimerTask() {
            @Override
            public void run() {
                client.sendTCP(new Ping());
            }
        }, 1000, 1000);
    }

Jak już pisałem wcześniej, jedyną zasadniczą różnicą pomiędzy tworzeniem klienta a serwerem jest zamiana klasy oraz metody wykonywanej w bloku try...catch:

        try {
            client.connect(TIMEOUT, HOST, PORT_TCP, PORT_UDP);
        } catch (IOException ex) {
            ex.printStackTrace();
        }

Dalej tworzymy jakąś instancję sprawczą, która co sekundę(1000 ms) będzie wysyłać obiekt Ping do serwera.

        Timer timer = new Timer();
        timer.schedule(new TimerTask() {
            @Override
            public void run() {
                client.sendTCP(new Ping());
            }
        }, 1000, 1000);

Teraz musimy zdefiniować logikę klienta, poprzez własną implementacje Listenera nasłuchującego zdarzenia sieciowe:

    class ClientListener extends Listener {
        @Override
        public void received(Connection connection, Object object) {
            System.out.println("Odpowiedź od serwera: " + object);
        }
    }

Polega ona na wyświetleniu odpowiedzi zwrotnej z serwera na konsoli.

Poniżej przedstawiam jeszcze dwie klasy obiektów, przesyłane przez naszego klienta i serwer:

Ping:

package pl.silver.kryo;

public class Ping{
    @Override
    public String toString() {
        return "Ping";
    }
}

Pong:

package pl.silver.kryo;

public class Pong{
    @Override
    public String toString() {
        return "Pong";
    }
}

Efektem działania programu będzie takie wyjście konsolowe:

00:00  INFO: [kryonet] Server opened.
Ready set to true : true
00:00  INFO: Connecting: /127.0.0.1:56555/56777
00:00  INFO: Wrote: {
class: RegisterTCP,
connectionID: 1
}
00:00  INFO: Wrote: {
class: RegisterUDP,
connectionID: 1
}
00:00  INFO: Wrote: {
class: RegisterUDP
}
00:00  INFO: [kryonet] Connection 1 connected: /127.0.0.1
00:00  INFO: [kryonet] Connection 1 connected: /127.0.0.1
00:01  INFO: Wrote: {
class: pl.silver.kryo.Ping
}
Otrzymano od: 1 obiekt: Ping
00:01  INFO: Wrote: {
class: pl.silver.kryo.Pong
}
Odpowiedź od serwera: Pong
00:02  INFO: Wrote: {
class: pl.silver.kryo.Ping
}
Otrzymano od: 1 obiekt: Ping
00:02  INFO: Wrote: {
class: pl.silver.kryo.Pong
}
Odpowiedź od serwera: Pong
00:03  INFO: Wrote: {
class: pl.silver.kryo.Ping
}
Otrzymano od: 1 obiekt: Ping
00:03  INFO: Wrote: {
class: pl.silver.kryo.Pong
}
Odpowiedź od serwera: Pong
00:04  INFO: Wrote: {
class: pl.silver.kryo.Ping
}
Otrzymano od: 1 obiekt: Ping
00:04  INFO: Wrote: {
class: pl.silver.kryo.Pong
}
Odpowiedź od serwera: Pong
00:05  INFO: Wrote: {
class: pl.silver.kryo.Ping
}
Otrzymano od: 1 obiekt: Ping
00:05  INFO: Wrote: {
class: pl.silver.kryo.Pong
}

Jak widzimy, kod naszej aplikacji działa poprawnie. Czyli dla każdego wysłanego obiektu przez klienta Ping, serwer zwraca odpowiedź w postaci obiektu Pong.

00:04  INFO: Wrote: {
class: pl.silver.kryo.Pong
}

Dodatkowe dane pokazywane w konsoli pochodzą z obiektu JsonSerialization  , i stanową surowy podgląd danych json przesyłanych przez sieć.