See demoprojekt on hoitud võimalikult lihtsana, et põhifookus hoida mitte mängu loogikal endal, vaid kliendi ja serveri ülespanekul ning selle korralikul ja õige arhitektuuri loomisel. Siinloodud serveri transformeerime hiljem üheks rasvaseks jar-failiks ja paneme selle ülikooli masinasse kliente serveerima.

2. Klient & server: chat demo

Paneme püsti oma esimese “mängu” anonchat, kus kasutajad saavad üksteisega väga salajaselt sõnumineerida 🥷

img_7.png

Õpetus eeldab, et kasutame IntelliJ IDEA’d ja et meil juba olemas mingi esialgne projekt loodud libGDXi abiga. Hoiame serveri ja kliendi ühes projetkis, mis teeb koodi jagamise ja ühiste seadistuste kasutamise lihtsamaks.

2.1. Üldine setup

  1. Loome juuritasandil uued moodulid shared ja server

    Paremklõps juurkataloogil > New > Module...

    img_8.png

    Sõltuvalt sellest, kuidas te oma projekti libGDX kaudu seadistasite, võivad teie projekti arhitektuuris olla mõned erinevused, kuid peamine on, et need kaks moodulit oleksid loodud.

  2. Lisame shared, core ja server modulites build.gradle faili KryoNet-i dependency:

    // ...
    dependencies {
        // ...
        implementation 'com.esotericsoftware:kryonet:2.22.0-RC1'
    }
    // ...
    
  3. Vajutame ilmunud “Load Gradle Changes” ikoonile img_4.png

    NB!

    Kui ikooni ei ilmu, võib kontrollida ekraani allservast, kas background taskid jooksevad veel

img_troubleshoot.png

2.2. Shared kaust

2.2.1. Milleks see üldse on?

Võrgumängus või -rakenduses peavad nii klient kui ka server jagama samu sõnumeid (messages), mis liiguvad nende vahel suhtluseks. Need sõnumid on objektid või andmestruktuurid, mis kannavad infot, näiteks kasutaja tekstisõnum, mängija liikumine või muud käsklused.

Kui kumbki hoiaks neid sõnumeid eraldi, on oht, et kliendi ja serveri sõnumite vormingud erineksid ja nad ei saaks enam omavahel suhelda.

Kataloog shared lahendab selle probleemi:

  • Siia pannakse kõik ühised sõnumiklassid (messages), mida nii klient kui server kasutavad.

  • Nii on kindel, et mõlemad pooled kasutavad täpselt samu sõnumite formaate ja struktuure.

  • Siin ka võivad olla teised andmed, näiteks konstandid, liidesed või abstraktsed klassid.

2.2.2. Messages

  1. Loome uue paki (shared/src/main/java kaustale ja New > Package), nt messages

  2. Loome paki sisse kaks uut faili (klassi) TextMsg.java ja PeopleCountMsg.java

TextMsg.java kood:

package messages;

public class TextMsg {
   public String text;
   boolean isSentByServer = false;
   public int senderId;

   public TextMsg(String text, int id) {
      this.text = text;
      this.senderId = id;
   }

   public TextMsg() {}

   public String getText() {
      return text;
   }

   public int getSenderId() {
      return senderId;
   }

   public void setSentByServer() {
      this.isSentByServer = true;
   }

   public boolean isSentByServer() {
      return isSentByServer;
   }
}

PeopleCountMsg.java kood:

package messages;

public class PeopleCountMsg {
   public int number;

   public PeopleCountMsg() {}

   public void setNumber(int number) {
      this.number = number;
   }

   public int getNumber() {
      return number;
   }
}

Mida saab öelda sõnumite kohta:

  1. Igas sõnumiklassis peab olema tühi (ilma parameetriteta) konstruktor, sest KryoNet kasutab seda objekti loomisel andmete lahtipakkimiseks (deserialiseerimiseks). Kui tühja konstruktorit ei ole, ei suuda KryoNet sõnumit korrektselt vastu võtta ega luua uut objekti, sest ta ei tea, kuidas seda ilma parameetriteta konstrueerida.

  2. Sõnumid on lihtne andmeedastusobjekt, mis ei sisalda keerulist loogikat, vaid salvestab ainult andmed, mis tuleb edastada kliendi ja serveri vahel.

  3. Sõnumites võivad olla nii setterid kui ka getterid – nende olemasolu sõltub konkreetsetest nõuetest ja sõnumi kasutamise loogikast. Mõnikord piisab ainult avalikest väljadest, mõnikord on vaja täiendavat kapseldamist juurdepääsumeetodite kaudu, et kontrollida andmete muutusi.

2.2.3. Kryo.Register

  1. Loome uue paki (shared/src/main/java kaustale), nt network.register

  2. Loome paki sisse uue faili Register.java

Register.java kood:

package network.register;

import com.esotericsoftware.kryo.Kryo;
import messages.TextMsg;
import messages.PeopleCountMsg;

public class Register {
   // This registers objects that are going to be sent over the network.
   public static void register(Kryo kryo) {
      kryo.register(TextMsg.class);
      kryo.register(PeopleCountMsg.class);
   }
}

Mis see on?

  1. kryo.register(...) on vajalik selleks, et registreerida klass Kryo serialiseerimissüsteemis, et seda saaks korrektselt ja kiiresti üle võrgu edastada.

  2. Kui objekti ei registreerita, siis selle saatmisel üle võrgu annab Kryo vea, et klass on tundmatu. Seetõttu on oluline harjumus — registreerida alati kõik sõnumiklassid (ja kõik muud edastatavad objektid).

  3. Kõige parem on registreerimine teha ühises kohas, shared kaustas. Nii kasutavad server ja klient sama registreerimiskoodi ning ei ole vaja iga Message-t kahes kohas käsitsi lisada. See tagab, et registreerimise järjekord on sama ning väldib andmeedastuse vigu.

2.3. Klient

2.3.1. Setup

  1. Loome uue paki (core/src/main/java kaustale), nt ee.example.client

  2. Loome paki sisse uut faili ChatClient.java

  3. Loome selle paki sisse veel kaks uut paki, nt listener ja manager

  4. Loome vastavatesse pakkidesse failid ClientListener.java ja ClientManager.java

    See peaks tulema nii välja:

    img_3.png

  5. Kuna me kasutame objekte kaustast shared, siis tuleb lisada sõltuvus ka meie faili build.gradle kaustas core

    // ...
    dependencies {
       // ...
       implementation 'com.esotericsoftware:kryonet:2.22.0-RC1'
       implementation project(':shared')
    }
    // ...
    

2.3.2. Kood

Alustame ChatClient.java:

package ee.example.client;

import com.esotericsoftware.kryonet.Client;
import com.esotericsoftware.minlog.Log;
import ee.example.client.listener.ClientListener;
import messages.TextMsg;
import network.register.Register;

import java.io.IOException;
import java.util.Scanner;

public class ChatClient {
   private static final String SERVER_IP = "127.0.0.1";
   private static final int TCP_PORT = 8080;
   private final Client client;
   private final ClientListener clientListener;

   public ChatClient() throws IOException {
      Log.set(Log.LEVEL_INFO);  // Log.LEVEL_INFO to reduce verbosity in logs
      client = new Client();  // Creating a KryoNet client
      client.start(); // Start the client's networking threads
      clientListener = new ClientListener(this);

      // Register all classes that will be sent over the network
      // That's why we created a separate Register class in shared,
      // so that we could write object registration in a single line
      Register.register(client.getKryo());

      bindServer(); // Connect to the server
      client.addListener(clientListener); // Add client event listener
   }

   private void bindServer() throws IOException {
      try {
         // Connect to server with a 15-second timeout
         client.connect(15000, SERVER_IP, TCP_PORT);
         System.out.println("Connected to server: " + SERVER_IP + ":" + TCP_PORT);
      } catch (IOException e) {
         e.printStackTrace();
         System.err.println("Error starting the client: " + e.getMessage());
         System.err.println("Make sure the server is running, reachable, and that the correct IP address and port are set.");
         System.exit(1);
         throw e;
      }
   }

   public Client getClient() {
      return client;
   }

   public void sendMessage(String text) {
      // Create a message object with text
      TextMsg msg = new TextMsg(text, client.getID());
      client.sendTCP(msg); // Send a message via TCP
   }

   public static void main(String[] args) throws IOException {
      ChatClient client = new ChatClient();

      // Reading messages from the console and sending them to the server
      Scanner scanner = new Scanner(System.in);
      while (true) {
         String input = scanner.nextLine();
         client.sendMessage(input);
      }
   }
}

Järgmiseks ClientListener.java:

package ee.example.client.listener;

import com.esotericsoftware.kryonet.Connection;
import com.esotericsoftware.kryonet.FrameworkMessage;
import com.esotericsoftware.kryonet.Listener;
import com.esotericsoftware.minlog.Log;
import ee.example.client.ChatClient;
import ee.example.client.manager.ClientManager;
import messages.PeopleCountMsg;
import messages.TextMsg;

public class ClientListener extends Listener {
   private final ClientManager clientManager;

   public ClientListener(ChatClient chatClient) {
      // Initialize the manager responsible for handling client-side logic
      clientManager = new ClientManager(chatClient);
   }

   public void connected(Connection c) {
      // Called when the client successfully connects to the server
      clientManager.playerConnected(c);
   }

   @Override
   public void received(Connection c, Object object) {
      // Called when a message or object is received from the server
      switch (object) {
         case TextMsg message ->
            // Handle incoming chat message from the server
            clientManager.playerSentText(message);

         case PeopleCountMsg message ->
            // Handle message containing the number of connected players
            clientManager.numberOfPeople(message);
         case FrameworkMessage.KeepAlive ignored ->
            // Handle KryoNet's KeepAlive heartbeat message
            // (used internally to keep the TCP connection alive)
            Log.debug("Received KeepAlive message");
         default -> {
            // Can be ignored
         }
      }
   }
}

Lõpuks ClientManager.java:

package ee.example.client.manager;

import com.esotericsoftware.kryonet.Connection;
import ee.example.client.ChatClient;
import messages.PeopleCountMsg;
import messages.TextMsg;

public class ClientManager {
   private final ChatClient chatClient;

   public ClientManager(ChatClient chatClient) {
      // Store reference to the main ChatClient to send messages back to server
      this.chatClient = chatClient;
   }

   public void playerConnected(Connection c) {
      // Called when connection to the server is established
      // Print out the unique connection ID assigned by the server
      System.out.printf("Connected to the server: your ID is %d%n", c.getID());

      // Send a PeopleCountMsg to the server to request current number of connected clients
      PeopleCountMsg msg = new PeopleCountMsg();
      chatClient.getClient().sendTCP(msg);
   }

   public void playerSentText(TextMsg message) {
      // Handle incoming chat message
      if (message.isSentByServer()) {
         // If message was not sent by any player, just print the text
         System.out.println(message.getText());
      } else {
         // Otherwise, print message with sender's ID for identification
         System.out.println("#" + message.getSenderId() + ": " + message.getText());
      }
   }

   public void numberOfPeople(PeopleCountMsg message) {
      // Handle message containing number of connected people
      // Print a welcome message showing how many other users are online
      String text = "Welcome to anonchat. " + message.getNumber() + " other users online.";
      System.out.println(text);
   }
}

Mis siin toimub?

  1. Klient käivitub ja ühendub serveriga.

    Ühendamiseks peab klient teadma serveri IP-aadressi ja porti (meie puhul 127.0.0.1 ja port 8080).

  2. Klient kuulab klaviatuurilt sisestust ja saadab sisestatud teksti serverile.

  3. Klient võtab serverilt sõnumeid vastu ja kuvab need konsoolis.

  4. Ühendudes saab klient unikaalse ID, mille määrab server, ning teab, kui palju inimesi on hetkel võrku ühendatud.

  5. Selleks, et klient saaks serverile sõnumi saata, tuleb kasutada käsku client.sendTCP() (või client.sendUDP()). Just sellepärast edastame me ChatClient objekti ClientListener kaudu edasi ClientManager klassi — et sealt saaksime vajadusel kliendi objekti kätte (getClient() meetodiga) ja sõnumi saata.

    See on hea näide sellest, kuidas objektid OOP-s saavad üksteisele edasi anda viiteid ja andmeid, et teha koostööd ilma kogu loogikat ühte kohta koondamata.

  6. Sissetulevate sõnumite töötlemiseks on kliendil eraldi klass ClientListener. ClientListener'i tähtsamad sündmused on:

    • connected — kui klient on serveriga ühendatud,

    • disconnected — kui ühendus katkeb,

    • received — kui serverilt tuleb sõnumeid või andmeid.

    Just received meetod võtab vastu kõik serveri sõnumid ja andmed.

  7. Võiks kirjutada töötlemise otse listeneri lisamisel,

    /// ...
    client.addListener(new Listener() {
        // message processing here
    });
    // ...
    

    kuid koodi kasvades muutub see ebamugavaks. Seetõttu eraldasime loogika eraldi klassi, et kood oleks puhtam ja lihtsam hooldada.

  8. Tegemist on klassiga ClientManager, kuhu pannakse sõnumite töötlemise loogika — mida teha saadud andmetega.

    Me oleksime võinud kirjutada kogu loogika, kuidas serverilt saadud sõnumitega töötada, otse ClientListener klassi, kuid siis muutuks kood liiga suureks ja keeruliseks. Seetõttu eraldasime selle loogika eraldi klassi ClientManager, mis teeb koodi puhtamaks ja lihtsamaks hallata.

    Tulevikus saab luua mitu erinevat managerit, olenevalt ülesannetest või meeskonna oskustest, näiteks LobbyManager või PlayerManager. See aitab paremini organiseerida koodi ja jagada vastutust erinevate funktsioonide vahel.

    ClientListener ainult võtab sõnumeid vastu, aga ClientManager otsustab, kuidas nendele reageerida.

Kliendiga meil on kõik valmis, liigume serverile!

2.4. Server

2.4.1. Setup

Kordame kõike nagu kliendi puhul: loome uue paki, nimeks näiteks ee.example.server, mille sees on veel kaks paki listener ja manager. Loome vastavatesse pakkidesse failid ChatServer.java ja ServerListener.java ja ServerManager.java.

Lisame jälle sõltuvus ka meie faili build.gradle

// ...
dependencies {
   // ...
   implementation 'com.esotericsoftware:kryonet:2.22.0-RC1'
   implementation project(':shared')
}
// ...

2.4.2. Kood

Alustame ChatServer.java:

package ee.example.server;

import com.esotericsoftware.kryonet.Server;
import com.esotericsoftware.minlog.Log;
import ee.example.server.listener.ServerListener;
import network.register.Register;

import java.io.IOException;

public class ChatServer {
   private static final int TCP_PORT = 8080;
   private final Server server;
   private final ServerListener serverListener;

   public ChatServer() throws IOException {
      Log.set(Log.LEVEL_INFO);

      server = new Server();
      serverListener = new ServerListener(this);

      Register.register(server.getKryo());

      bindServer();

      server.addListener(serverListener);
      server.start();
   }

   private void bindServer() throws IOException {
      try {
         server.bind(TCP_PORT);
         System.out.println("Server started on port: " + TCP_PORT);
      } catch (IOException e) {
         System.err.println("Error starting the server: " + e.getMessage());
         System.err.println("Failed to bind server!");
         throw e;
      }
   }

   public Server getServer() {
      return server;
   }

   public static void main(String[] args) throws IOException {
      ChatServer server = new ChatServer();
   }
}

Järgmiseks ServerListener.java:

package ee.example.server.listener;

import com.esotericsoftware.kryonet.Connection;
import com.esotericsoftware.kryonet.FrameworkMessage;
import com.esotericsoftware.kryonet.Listener;
import com.esotericsoftware.minlog.Log;
import ee.example.server.ChatServer;
import ee.example.server.manager.ServerManager;
import messages.PeopleCountMsg;
import messages.TextMsg;


public class ServerListener extends Listener {
   private final ServerManager serverManager;

   public ServerListener(ChatServer chatServer) {
      serverManager = new ServerManager(chatServer);
   }

   public void connected(Connection c) {
      serverManager.playerConnected(c);
   }

   public void disconnected(Connection c) {
      serverManager.playerDisconnected(c);
   }

   @Override
   public void received(Connection c, Object object) {
      switch (object) {
         case TextMsg message -> serverManager.playerSentText(c, message);
         case PeopleCountMsg message -> serverManager.numberOfPeopleRequest(c, message);
         case FrameworkMessage.KeepAlive ignored ->
            Log.debug("Received KeepAlive message"); // Handles KryoNet's heartbeat packet to keep the connection alive
         default -> {
            // Can be ignored
         }
      }
   }
}

Lõpuks ServerManager.java

package ee.example.server.manager;

import com.esotericsoftware.kryonet.Connection;
import ee.example.server.ChatServer;
import messages.PeopleCountMsg;
import messages.TextMsg;

import java.util.ArrayList;
import java.util.List;

public class ServerManager {
   private final ChatServer chatServer;
   private final List<Integer> players = new ArrayList<>();

   public ServerManager(ChatServer chatServer) {
      this.chatServer = chatServer;
   }

   public void playerConnected(Connection c) {
      int clientId = c.getID();
      players.add(clientId);

      String text = "User #%d has joined the chat.".formatted(clientId);
      sendMessageToAllExcept(clientId, text);
   }

   public void playerDisconnected(Connection c) {
      int clientId = c.getID();
      players.removeIf(id -> id == clientId);
      String text = "User #%d has left the chat.".formatted(clientId);
      sendMessageToAllExcept(clientId, text);
   }

   public void playerSentText(Connection c, TextMsg message) {
      String text = "Received message: '%s' from client with ID='%d'"
              .formatted(message.getText(), c.getID());
      System.out.println(text);
      sendMessageToAll(message.getText(), c.getID());
   }

   public void numberOfPeopleRequest(Connection c, PeopleCountMsg message) {
      message.setNumber(players.size() - 1);
      chatServer.getServer().sendToTCP(c.getID(), message);
   }

   private void sendMessageToAll(String text, int id) {
      TextMsg msg = new TextMsg(text, id);
      chatServer.getServer().sendToAllTCP(msg);
   }

   private void sendMessageToAllExcept(int clientId, String text) {
      TextMsg msg = new TextMsg(text, -1);
      msg.setSentByServer();
      chatServer.getServer().sendToAllExceptTCP(clientId, msg);
   }
}
  1. Üldine struktuur on kliendipoolega sarnane. Need klassid (ChatServer, ServerListener, ServerManager) on arhitektuurilt väga sarnased kliendipoolsetele (ChatClient, ClientListener, ClientManager). Rollid on jaotatud samamoodi, aga töötavad kahes erinevas otsas.

  2. Samamoodi registreerime kõik sõnumiklassid serveri poolel ning loogikat jagama eraldi klassidesse. ServerListeneris kasutatakse samu sündmusi.

  3. Server saab klientidele sõnumeid saata järgmiste käskudega:

    • server.sendToTCP(id, object) — saadab sõnumi konkreetsele kliendile.

    • server.sendToAllTCP(object) — saadab sõnumi kõigile klientidele.

    • server.sendToAllExceptTCP(id, object) — saadab sõnumi kõigile peale ühe kliendi.

    Nende käskude jaoks on vaja Server objekti, mis luuakse ChatServer klassis ja antakse edasi teistesse klassidesse. Selle saab kätte meetodiga getServer(), täpselt samamoodi nagu kliendipoolel kasutatakse getClient() meetodit sõnumite saatmiseks serverile.

Serveriga meil on kõik. Proovime meie chati tööle panna!

2.5. Chati käivitamine ja töövoog

2.5.1. Chati käivitamine

Serveri kaustas paneme käima serveri ChatServer.java.

Core kaustas paneme käima kliendi ChatClient.java.

Kui kõik õnnestub ja erroreid ei visata, saame kliendi konsoolist kirjutada sõnumeid. Juhuu!

Aga kus on teised kliendid? IntelliJ-s ühes projektis mitme kliendi korraga jooksutamiseks (samal ajal mitu ChatClient.java) vali: Current File > Run with Parameters... > Modify options > Allow multiple instances img_5.png img_6.png

2.5.2. Chati töövoog serveri ja kliendi tasemel

  1. Klient ühendub serveriga -> connected sündmus nii kliendil, kui ka serveril

    • Server:

      • Ühendunud klient lisatakse klientide nimekirja, et jälgida online olevate inimeste arvu.

      • Kõigile ülejäänud klientidele saadetakse TextMsg, mis sisaldab teavitust, et uus klient liitus. Sõnumil on märge, et see pärineb serverist, mitte mõnelt konkreetse ID-ga kliendilt. (Alternatiivselt oleks võinud luua eraldi nt NotificationMsg, et eristada teadete tüüpi ja lihtsustada TextMsg kasutust.)

    • Klient

      • Konsooli prinditakse serveri määratud kliendi ID.

      • Serverile saadetakse PeopleCountMsg palvega teada saada online olevate inimeste arv. Server tagastab vastuse, mille järel kliendi konsoolis kuvatakse tervitusteade.

  2. Sõnumi saatmine

    • Kui klient kirjutab teksti konsooli, saadetakse serverile TextMsg.

    • Server prindib konsooli info saadetud sõnumi ja selle autori kohta.

    • Seejärel saadetakse sõnum kõigile klientidele, sealhulgas saatjale, et nende terminalis kuvataks tekst koos saatja ID-ga.

  3. Klient lahkub -> disconnected sündmus serveril

    • Kliendi ID eemaldatakse online klientide nimekirjast.

    • Kõigile ülejäänud klientidele saadetakse teade, et konkreetne klient lahkus.

See ongi praegu kõik! Edu projekti alustamisega.


2.6. Demoprojektid ja muud huvitavat

  • Vaata ka neid demoprojekte, mis meenutavad juba rohkem mängu:

  • Võib olla huvitav analüüsida loodavat võrguliiklust ka baitide tasemel, näiteks Wireshark’i abil: img_2.png

  • Tihti tasub enne koodi kirjutamist sketšida välja, kuidas suhtlus klientide-serveri vahel olema hakkab. Selleks võib olla kasulik nt paber või https://excalidraw.com/


2.7. KKK (Korduma Kippuvad Küsimused)

Kui on küsimusi, kirjuta Discordi

Q: Kas peab tingimisi olema Java 17? KryoNet? Gradle?
A: Ei. Valikuid võite teha muidki, kuid siis võib olla väiksem tõenäosus, et keegi (kas mõni abiõppejõud, otsingumootor või keelemudel) oskab aidata.
  • Java: 2023. a sügisel tuli välja viimane Java LTS (Long-Term Support) versioon 21. Kuna nii KryoNet kui LibGDX on vanemad olijad ja uhiuue Java 21 implementatsioonid võivad olla veel “välja lihvimata”, võib tekkida probleeme (ei pruugi, katsetage)

  • Gradle: kuna enamik varasemaid projekte on Gradle’ga, soovitame Gradle’t (mitte nt Maven). Lisaks, 2023. a on Gradle’i vaikimisi DSL (Domain Specific Language) Kotlin, mitte Groovy. Hetkel aga tundub, et palju rohkem näiteid leidub internetist Groovy-ga

  • KryoNet: kuna enamik varasemaid projekte on KryoNet’iga, soovitame KryoNet-i (mitte nt Netty, mis vajab rohkem isetegemist). KryoNet-i pole küll pikalt uuendatud, kuid see on lihtne ja töötab hästi (TCP pole muutunud). KryoNet-ist on ka uuendatud fork’e, kõige populaarsem crykn/kryonet - võib jällegi katsetada ja öelda, kuidas läks

Q: Miks KryoNet-i vaja on?
A: KryoNet lihtsustab meie jaoks oluliselt madalama taseme võrgundust. Peale TCP/UDP ühenduste loomise ja hoidmise ning mugavate API-de nagu sendToAllExceptTCP(...) pakkumise kasutab KryoNet sisemiselt Kryo-t, mis teisendab mälus olevad Java objektid (nt Message) üle võrgu saadetavateks baitideks. Seda protsessi nimetatakse “serialiseerimiseks” (serialization/marshalling/pickling) ja vastupidist, nö raw bytes -> object, nimetatakse “deserialiseerimiseks”.