해당 글은 2023. 10. 7에 작성되었습니다.
네트워크 내에서 Socket의 역할을 좀 더 명확하게 이해하기 위해 JAVA를 이용한 기초적인 socket programming 예제를 진행하였다.
네트워크 프로그래밍은 곧 Socket Programming이라 해도 과언이 아니다. 결국 Application은 Socket을 통해서 data를 주고 받는 network를 이용할 수 있기 때문이다.
소켓(Socket)
- Application Layer와 Transport Layer 사이의 Interface 역할을 한다
- Application과 network 사이의 API(Application Programming Interface)
네트워크를 통한 Server와 Client의 메세지 통신, TCP connection을 설명할 때, 모두 Socket을 통한 통신을 전제로 한다. Socket의 역할을 명확하게 이해할 수 있는 Code를 보자.
예제의 주제와 요구사항은 다음과 같다.
Develop an Internet calculator program using Java
-> Create client and server programs using Java socket API
-> Get an (arithmetic) expression from client side, and Server solves the expression and sends the result back to
the client
Define the protocol (communication message formats) for this application
Protocol for Command/Response Interaction
• You should define ASCII-code based message formats
• Commands and responses formats
• Refer: HTTP request/response formats
The four basic arithmetic operations - addition, subtraction, multiplication and division – should be supported
The response from the server may be either answer for the expression or error message (and error type)
The server must handle multiple clients at a time
Server information at a client is stored with a configuration file (e.g., serverinfo.dat)
• client programs read Server IP and port # from the file
• If file does not exist, set default information (e.g.,localhost, port 1234)
Server와 Client와 Socket을 통해 메세지를 주고 받으며 계산기 작동을 해야하는 큼직한 메인 기능 구현외에도 여러가지 요구사항이 있다. 다음과 같다.
- 입력의 유효성을 판단하여 적당한 메세지를 전달하여야 한다. (Exception Handle 필수)
- Server는 동시에 다수의 Client를 handle할 수 있어야 한다.
- Server 정보는 serverinfo.dat라는 설정 파일로부터 받아온다.
- Command/Response 상호작용에서의 메세지 형식은 HTTP처럼 ASCII-code 기반이어야 한다.
그 중 2,4번을 구현하며 유의미함을 느꼈다. 특히 4번을 구현하면서 많은 오류를 만났다. Flush로 buffer를 비워주기도 하고, 데이터 손실을 체크하기도 하고, ASCII code로의 메세지 변환에서 발생하는 예외도 체크해야 했다. 다음은 두 구현에 대한 설명이다.
. Multi-client support
ClientHandler 클래스와 스레드(Thread)를 활용
ClientHandler 클래스는 각 클라이언트와의 연결을 처리하며, 별도의 스레드에서 실행된다.
서버는 클라이언트의 연결 요청마다 ClientHandler 스레드를 생성하여 병렬적으로 클라이언트 요청을 처리할 수 있다.
이를 통해 서버는 여러 클라이언트와 동시에 상호작용하고 다중 클라이언트의 요청을 병렬로 처리하는데, 이때 클라이언트 간에 작업이 서로 영향을 주지 않는다.
클라이언트가 연결을 종료하면 해당 ClientHandler 스레드가 종료되고 연결이 닫힌다.
Message Format :
- 프로토콜은 클라이언트와 서버 간의 메시지를 정의하는데, ASCII 코드로 표현된다. 예를 들어, 클라이언트가 서버에게 ADD 10 20과 같은 연산을 요청하면, 이 메시지는 ASCII 코드 형식으로 전송된다.
- 프로토콜은 메시지의 유효성을 검사하는데, 클라이언트가 잘못된 형식의 메시지를 보내거나 유효하지 않은 연산을 요청할 때 서버는 오류 메시지를 반환하여 client에게 전달한다.
- 이렇게 정의된 프로토콜은 클라이언트와 서버가 메시지를 교환하는 통신 규약이 된다. ASCII 코드를 기반으로 하므로 메시지의 읽기와 해석이 용이하며, 오류 처리 및 유효성 검사도 효과적으로 수행할 수 있다. 이를 통해 효율적이고 안정적인 client-server 상호작용이 가능하게 된다.
나머지는 자세한 Comments를 달아놓았으니 Code를 보도록 하자.
import java.io.*;
import java.net.*;
public class Client {
public static void main(String argv[]) throws Exception {
// Get the server's IP address and port number from the configuration file
String serverIP = Config.getServerIP();
int portNumber = Config.getPortNumber();
// Create a socket to make a connection with the server
Socket clientSocket = new Socket(serverIP, portNumber);
// Buffer reader for user input
BufferedReader inFromUser = new BufferedReader(new InputStreamReader(System.in));
// Output stream to send data to the server
DataOutputStream outToServer = new DataOutputStream(clientSocket.getOutputStream());
// Buffer reader to read responses from the server
BufferedReader inFromServer = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
// Waiting for user's input
while (true) {
System.out.print("Enter an expression (e.g., ADD 10 20) or type EXIT to quit: ");
String sentence = inFromUser.readLine();
// Close the socket and exit if "EXIT" is entered
if (sentence.equalsIgnoreCase("EXIT")) {
outToServer.writeBytes(Utility.toASCIIString(sentence) + '\n');
outToServer.flush();
clientSocket.close();
break;
}
// Send the user's input to the server in ASCII format
outToServer.writeBytes(Utility.toASCIIString(sentence) + '\n');
outToServer.flush();
// Read the response from the server (and decode from ASCII format)
String modifiedSentence = Utility.fromASCIIString(inFromServer.readLine());
System.out.println("FROM SERVER: " + modifiedSentence);
}
}
}
import java.io.*;
import java.net.*;
public class Server {
public static void main(String argv[]) throws Exception {
// Get the port number from the configuration file
int portNumber = Config.getPortNumber();
// Create a server socket to listen for client connection requests
ServerSocket welcomeSocket = new ServerSocket(portNumber);
System.out.println("Server started on port " + portNumber + "...");
// Waiting for connection
while (true) {
// Accept a connection request from a client
Socket connectionSocket = welcomeSocket.accept();
// Handle the connection in a new thread
new ClientHandler(connectionSocket).start();
}
}
}
class ClientHandler extends Thread {
private Socket connectionSocket;
private BufferedReader inFromClient;
private DataOutputStream outToClient;
public ClientHandler(Socket connectionSocket) {
this.connectionSocket = connectionSocket;
try {
// Create an input stream from the client for receiving data
this.inFromClient = new BufferedReader(new InputStreamReader(connectionSocket.getInputStream()));
// Create an output stream to the client for sending data
this.outToClient = new DataOutputStream(connectionSocket.getOutputStream());
} catch (IOException e) {
e.printStackTrace();
}
}
@Override
public void run() {
try {
while (true) {
String asciiReceived = inFromClient.readLine();
// If there's no more data from client, break
if (asciiReceived == null) break;
String decodedExpression = Utility.fromASCIIString(asciiReceived);
// If "EXIT" is received, break
if (decodedExpression.equalsIgnoreCase("EXIT")) {
break;
}
// Evaluate the expression
String result = Utility.evaluateExpression(decodedExpression);
// Send the result to the client in ASCII format
outToClient.writeBytes(Utility.toASCIIString(result) + '\n');
outToClient.flush();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
public class Protocol
{
public static final String ADD = "ADD";
public static final String SUB = "SUB";
public static final String MUL = "MUL";
public static final String DIV = "DIV";
// Define error messages as constants
public static final String ERROR_DIV_BY_ZERO = "Error: Division by zero";
public static final String ERROR_INVALID_OPERATION = "Error: Invalid operation";
}
import java.io.*;
import java.util.Properties;
public class Config {
private static final String CONFIG_FILE = "serverinfo.dat";
// Default values for server address and port
private static String SERVER_ADDRESS = "localhost";
private static int SERVER_PORT = 1234;
// Load configuration values from the file when the class is loaded
static {
Properties properties = new Properties();
try (InputStream input = new FileInputStream(CONFIG_FILE)) {
// Load the configuration file
properties.load(input);
// Read server address and port from the configuration file (use defaults if not present)
SERVER_ADDRESS = properties.getProperty("SERVER_ADDRESS", SERVER_ADDRESS);
SERVER_PORT = Integer.parseInt(properties.getProperty("SERVER_PORT", String.valueOf(SERVER_PORT)));
} catch (IOException e) {
// Use default values if the configuration file is not found
System.out.println("Configuration file not found. Using default values.");
}
}
// Get the server IP address
public static String getServerIP() {
return SERVER_ADDRESS;
}
// Get the port number
public static int getPortNumber() {
return SERVER_PORT;
}
}
public class Utility {
public static String toASCIIString(String str) {
StringBuilder asciiStr = new StringBuilder();
for (char ch : str.toCharArray()) {
asciiStr.append((int) ch).append(" "); // Appending ASCII value followed by a space
}
return asciiStr.toString().trim(); // Return the result without the trailing space
}
public static String fromASCIIString(String asciiStr) {
StringBuilder str = new StringBuilder();
try {
for (String asciiValue : asciiStr.split(" ")) {
str.append((char) Integer.parseInt(asciiValue)); // Convert each ASCII value back to character
}
} catch (NumberFormatException e) {
return "Error: Invalid ASCII format";
}
return str.toString();
}
// Evaluate the given expression and return the result
public static String evaluateExpression(String expression) {
String[] parts = expression.split(" ");
// Check the validation format of message
if (parts.length != 3)
{
return "Error: Invalid expression format";
}
String operation = parts[0];
String operand1 = parts[1];
String operand2 = parts[2];
// Check if operand1 and operand2 are valid numbers
try {
double num1 = Double.parseDouble(operand1);
double num2 = Double.parseDouble(operand2);
// Return the result based on the operation type
switch (operation) {
case "ADD":
return Double.toString(num1 + num2);
case "SUB":
return Double.toString(num1 - num2);
case "MUL":
return Double.toString(num1 * num2);
case "DIV":
// Return an error message if attempting to divide by zero
if (num2 == 0) {
return "Error: Division by zero";
}
return Double.toString(num1 / num2);
default:
// Return an error message for unsupported operation types
return "Error: Invalid operation";
}
} catch (NumberFormatException e) {
// Return an error message if operand1 or operand2 are not valid numbers
return "Error: Invalid number format";
}
}
}
최대한 Class를 나눠서 명확한 역할 분담을 하고 가독성을 높이고 싶었다.
기초적인 예제임에도 쉽지 않았다. 하지만 네트워크에서의 Socket의 비중은 한 층 더 체감했다.
끝!