O wątkach, socketach i komunikacji – piszemy własny serwer

Autor Autor:
Zespół Innokrea
Data publikacji: 2024-06-06
Kategorie: Administracja Programowanie

Hej, dzisiaj jako Innokrea chcemy z Wami porozmawiać na temat komunikacji międzyprocesowej, socketów oraz wątków. Jeśli ciekawi Was to jak można zaimplementować własny serwer w oparciu o wątki, to zapraszamy do lektury!

 

Architektura klient-serwer

W dzisiejszym świecie IT najpopularniejszą wśród ludzi (choć nie dominującą) architekturą w systemach jest klient-serwer, w której stacje kliencie wysyłają zapytanie o dany zasób do pewnego serwera posiadającego informacje. Model ten nazywany jest także modelem request-response, a przykładem jego zastosowania jest choćby tak popularne REST API. Inne architektury, które mogą być używane nawet częściej, to często architektury zdecentralizowane (bez centralnego serwera z zasobami). Internet jako sieć złożona z miliardów urządzeń jest właśnie przykładem takiego rozwiązania. Inne architektury o których można wspomnieć w tym kontekście to choćby peer-to-peer (Bittorrent), microservices czy model public-subscribe wykorzystywany przy event-driven architecture. Skomplikowane systemy wykorzystują często wiele paradygmatów i związanych z tym architektur.

 

architektura klient-serwer

Rysunek 1 – Architektura klient-serwer [1]

 

Sockets

Czym są sockety? To jeden z najbardziej podstawowych sposobów komunikacji w technologiach komputerowych. Proces zestawiania komunikacji polega na stworzeniu dwukierunkowego połączenia poprzez sieć komputerową. Każdy z endpointów nazywamy właśnie socket’em. Całość procesu wymaga zarezerwowania portu oraz adresu ip, co oznacza, że komunikacja wykorzystuje zarówno warstwę transportową jak i sieci modelu OSI.

 

Socket API

Rysunek 2 – Socket API, źródło [2]

 

Na powyższym rysunku możemy zaobserwować kolejne metody przez które musi przejść każdy z socketów, aby zmieniać stan swojego obiektu i przesłać dane. Metoda bind odpowiada na przykład za przypisanie odpowiedniego portu, a connect za wykonanie prośby o połączenie do drugiej maszyny używającej socket’ów.

 

Przykład prostego serwera

Spróbujemy napisać prosty serwer używając języka Java i środowiska InteliJ. Serwer będzie działał w oparciu o socket’y oraz używał pojedynczego wątku. Oznacza to, że jednocześnie będzie przetwarzał on tylko pojedyncze zapytanie od pojedynczego użytkownika. Taki serwer nazywamy iteratywnym. W celu stworzeniu takiego serwera wykorzystamy klasę ServerSocket z biblioteki java.net. Nasz program będzie miał za zadanie przyjąć dwie liczby całkowite a następnie zwrócić ich sumę.

package singleThreaded.Server;
import java.io.*;
import java.net.ServerSocket;
import java.net.Socket;
public class TCPServer {
   Integer portNumber;
   ServerSocket serverSocket;

   public  TCPServer(Integer portNumber) throws IOException {
       this.portNumber = portNumber;
       serverSocket = new ServerSocket(portNumber);
   }

   public void start() throws IOException {
       System.out.println("SERVER HAS BEEN STARTED \n");
       while (true){
           Socket clientSocket = serverSocket.accept();
           DataInputStream inputClientStream = new DataInputStream(clientSocket.getInputStream());
         
           int firstNumber = inputClientStream.readInt();
           int secondNumber = inputClientStream.readInt();
           int sum = firstNumber + secondNumber;

           System.out.println(firstNumber);
           System.out.println(secondNumber);

           // Send sum back to client
           DataOutputStream outputClientStream = new DataOutputStream(clientSocket.getOutputStream());
           outputClientStream.writeInt(sum);

           // Close client socket
           clientSocket.close();
       }
   }
}

 

package singleThreaded.Server;
import java.util.Scanner;

public class Program {
   public static void main(String[] args) {
       System.out.println("SINGLE-THREADER SERVER PROGRAM");

       Scanner scanner = new Scanner(System.in);
       System.out.println("Enter server port");
       Integer portNumber = Integer.valueOf(scanner.nextLine());
       try{
           TCPServer server = new TCPServer(portNumber);
           server.start();
       }
       catch (Exception e){
           System.out.println(e.getMessage());
       }
   }
}

 

W celu utworzenia klienta stosujemy klasę Socket, również z biblioteki java.net oraz zapytamy użytkownika jakie dwie liczby chce wysłać do operacji dla serwera.

package singleThreaded.Client;
import singleThreaded.Server.TCPServer;
import java.io.*;
import java.net.Socket;

public class TCPClient {
   String ipAddress;
   Integer portNumber;
   Socket clientSocket;

   public TCPClient(String ipAddress, Integer portNumber) throws IOException {
       this.ipAddress = ipAddress;
       this.portNumber = portNumber;
       this.clientSocket = new Socket(ipAddress, portNumber);
   }

   public void computeSum(Integer firstNumber, Integer secondNumber) throws IOException {
       System.out.println("CLIENT HAS BEEN STARTED\n");
       DataOutputStream outputStreamToServer = new DataOutputStream(clientSocket.getOutputStream());

       outputStreamToServer.writeInt(firstNumber);
       outputStreamToServer.writeInt(secondNumber);
       outputStreamToServer.flush();
       DataInputStream inputStreamFromServer = new DataInputStream(clientSocket.getInputStream());

       int sumFromServerString = inputStreamFromServer.readInt();
       System.out.println("SUM FROM SERVER : " + sumFromServerString);

       clientSocket.close();
   }
}

 

package singleThreaded.Client;
import java.util.Arrays;
import java.util.Scanner;

public class Program {
   public static void main(String[] args) {
       System.out.println("CLIENT PROGRAM");
       Scanner scanner = new Scanner(System.in);  // Create a Scanner object
       System.out.println("Enter server port");
       Integer portNumber = Integer.valueOf(scanner.nextLine().trim());
       System.out.println("Enter ip address");
       String ipAddress = scanner.nextLine().trim();
       System.out.println("Enter first number");
       Integer firstNumber = Integer.valueOf(scanner.nextLine().trim());
       System.out.println("Enter second number");
       Integer secondNumber = Integer.valueOf(scanner.nextLine().trim());
       try{
           TCPClient client = new TCPClient(ipAddress,portNumber);
           client.computeSum(firstNumber,secondNumber);
       }
       catch (Exception e){
           System.out.println(Arrays.toString(e.getStackTrace()));
           System.out.println(e.toString());
       }
   }
}

 

Istnieje jednak duży problem z powyższym kodem serwera. Za każdym razem kiedy klient łączy się z serwerem zajmuje jego jedyny, główny wątek jest zajęty wykonywaniem operacji, co oznacza, że inny klient nie może się połączyć. Aby temu zapobiec należy przy każdym połączeniu klienta obsługiwać jego żądanie na osobnym wątku.

 

Czym jest wątek?

Wątek jest częścią kodu w naszym programie wykonywaną współbieżnie. Jeśli chcemy, aby kod był wykonany w tym samym czasie, a zadanie, które ma wykonać może być obsłużone w tym samym czasie, to programowo w języku Java można to zaimplementować z użyciem interfejsu Runnable lub klasy Thread. Warto także powiedzieć, że wątki mogą w łatwy sposób współpracować dzięki współdzielonej przestrzeni adresowej, w przeciwieństwie do procesów. Preferowanym sposobem na implementowanie wielowątkowości jest zastosowanie interfejsu Runnable. Więcej o podstawowych przykładach wielowątkowości w Javie możesz przeczytać tutaj:

https://www.geeksforgeeks.org/runnable-interface-in-java/

 

Rozwiązanie wielowątkowe – socket server

W celu zaimplementowania funkcjonalności obsługi klienta na wielu wątkach w ramach serwera stworzymy klasę ClientHandler.

package multiThreaded.Server;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
class ClientHandler implements Runnable {
   private final Socket clientSocket;
   public ClientHandler(Socket clientSocket) {
       this.clientSocket = clientSocket;
   }

   @Override
   public void run() {
       try {
           DataInputStream inFromClient = new DataInputStream(clientSocket.getInputStream());

           // Read numbers sent from client
           int firstNumber = inFromClient.readInt();
           int secondNumber = inFromClient.readInt();
           int sum = firstNumber + secondNumber;

           // Send response back to client
           DataOutputStream outToClient = new DataOutputStream(clientSocket.getOutputStream());
           outToClient.writeInt(sum);

           // Close client socket
           clientSocket.close();
           System.out.println("Closing the connection for clientSocket port " + clientSocket.getPort());
       } catch (IOException e) {
           System.out.println("Error handling client connection: " + e.getMessage());
       }
   }
}

 

Natomiast klasę TCPServer z poprzedniego przykładu zmodyfikujemy w następujący sposób.

package multiThreaded.Server;
import java.io.*;
import java.net.ServerSocket;
import java.net.Socket;

public class TCPServer {
   Integer portNumber;
   ServerSocket serverSocket;

   public  TCPServer(Integer portNumber) throws IOException {
       this.portNumber = portNumber;
       serverSocket = new ServerSocket(portNumber);
   }

   public void start() throws IOException {
       System.out.println("Server Started \n");
       while (true){
           Socket clientSocket = serverSocket.accept();
           System.out.println("Starting new Thread for " + clientSocket.getPort());
           Thread thread = new Thread(new ClientHandler(clientSocket));
           thread.start();
       }
   }
}

 

Zwróćmy uwagę na tworzenie nowego wątku za każdym razem kiedy przychodzi połączenie (metoda accept odpowiada metodzie z diagramu 2) oraz przekazanie tam nowego obiektu ClientHandler razem z przyjętym połączeniem klienta (clientSocket).

Dzięki temu jesteśmy w stanie obsługiwać wiele klientów i procesować wiele ich żądań naraz. W celu uruchomienia programów, pamiętaj o zmianie nazwy paczki kodu na odpowiednią oraz o tym, że w przypadku uruchomienia serwera i klienta nadal potrzebujesz kodu z klas Program z rozwiązania jednowątkowego.

 

Podsumowanie

Dzisiaj dowiedzieliśmy się czym są wątki, sockety i jak można stworzyć prosty system działający w architekturze klient-serwer. Jeśli zainteresowaliśmy Was tym artykułem, to polecamy także zajrzeć na resztę artykułów na naszym blogu, gdzie omawiamy zagadnienia z zakresu programowania, cyberbezpieczeństwa czy sieci.

 

Źródła:

[1] https://darvishdarab.github.io/cs421_f20/docs/readings/client_server/

[2] https://www.javatpoint.com/socket-programming

Zobacz więcej na naszym blogu:

FastAPI – czyli jak napisać proste REST API w Pythonie? – część 1

FastAPI – czyli jak napisać proste REST API w Pythonie? – część 1

REST API w Pythonie? Nic prostszego. Zacznij z nami już dziś swoją przygodę z FastAPI!

Programowanie

Dockeryzacja frontendu – zrób to dobrze React.js + Vite

Dockeryzacja frontendu – zrób to dobrze React.js + Vite

Zrób to dobrze! Gotowy poradnik do dockeryzacji React.js z Vite.

Programowanie

O procesach, protobuf i RPC

O procesach, protobuf i RPC

Czym jest RPC, serializacja, komunikacja międzyprocesowa oraz jak wiąże się to z systemami rozproszonymi?

AdministracjaProgramowanie