Hej, hej... Programisto, to kolejny artykuł dla Ciebie! Druga część artykułu na temat wzorców projektowych. Poznaj Adapter oraz Memento.
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.
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.
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/