核心概念
在开始写代码之前,我们先理解几个核心概念:
- Socket (套接字):是网络通信的端点,你可以把它想象成一个电话插座,服务器和客户端都需要一个 Socket 才能进行通信。
- ServerSocket:这是服务器端使用的“总机”,它负责在指定的端口上监听客户端的连接请求,当一个客户端尝试连接时,
ServerSocket会接受这个请求,并返回一个新的Socket对象,用于与该客户端进行一对一的通信。 - IP 地址 和 端口号:IP 地址标识了网络上的唯一一台计算机,端口号标识了该计算机上的一个特定服务,客户端需要知道服务器的 IP 地址和端口号才能发起连接。
- 输入流 和 输出流:一旦
Socket连接建立,就可以通过它获取输入流和输出流,输入流用于读取从客户端发送过来的数据,输出流用于向客户端发送数据。
最简单的单线程服务器
这个服务器一次只能处理一个客户端连接,当一个客户端连接后,它会一直等待该客户端发送消息,直到客户端断开连接,才能接受下一个客户端的请求。
代码示例
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.ServerSocket;
import java.net.Socket;
public class SimpleServer {
public static void main(String[] args) {
// 定义服务器要监听的端口号
int port = 12345;
try (// 创建一个 ServerSocket,并绑定到指定的端口
ServerSocket serverSocket = new ServerSocket(port)) {
System.out.println("服务器已启动,正在监听端口 " + port + "...");
// 调用 accept() 方法,阻塞等待客户端连接
// 当有客户端连接时,accept() 方法会返回一个 Socket 对象
Socket clientSocket = serverSocket.accept();
System.out.println("客户端已连接: " + clientSocket.getInetAddress().getHostAddress());
// 获取客户端的输入流,用于读取客户端发送的数据
BufferedReader in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
// 获取客户端的输出流,用于向客户端发送数据
PrintWriter out = new PrintWriter(clientSocket.getOutputStream(), true);
String inputLine;
// 循环读取客户端发送的数据
while ((inputLine = in.readLine()) != null) {
System.out.println("收到客户端消息: " + inputLine);
// 如果客户端发送 "exit",则关闭连接
if ("exit".equalsIgnoreCase(inputLine)) {
System.out.println("客户端请求退出连接。");
break;
}
// 将收到的消息回显给客户端
out.println("服务器回显: " + inputLine);
}
} catch (IOException e) {
System.err.println("服务器异常: " + e.getMessage());
e.printStackTrace();
}
System.out.println("服务器已关闭。");
}
}
代码解析
-
ServerSocket serverSocket = new ServerSocket(12345);- 创建一个
ServerSocket实例,并让它监听本机的12345端口。 - 如果端口已被占用,会抛出
IOException。
- 创建一个
-
serverSocket.accept();- 这是服务器端的关键方法,它会阻塞程序的执行,直到有一个客户端连接到这个端口。
- 一旦有客户端连接,
accept()方法会返回一个Socket对象,这个Socket代表了与该客户端的专用连接通道。
-
new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));- 从
clientSocket获取输入流。InputStreamReader将字节流转换为字符流,BufferedReader提供了高效的按行读取功能。
- 从
-
new PrintWriter(clientSocket.getOutputStream(), true);- 从
clientSocket获取输出流。 PrintWriter提供了方便的println()方法来发送字符串。- 第二个参数
true表示自动刷新,每当调用println()方法后,输出缓冲区会自动刷新,确保数据能立即发送出去,这对于实时交互非常重要。
- 从
-
while ((inputLine = in.readLine()) != null)- 循环读取客户端发送的每一行数据。
readLine()会在读取到行末的换行符时返回,如果没有数据可读,则会阻塞,当客户端关闭连接时,readLine()会返回null,循环结束。
- 循环读取客户端发送的每一行数据。
多线程服务器
单线程服务器有很大的局限性,如果一个客户端连接后长时间不发送数据,其他客户端就无法连接,为了解决这个问题,我们需要为每个客户端连接创建一个独立的线程来处理。
代码示例
我们将创建一个 ClientHandler 类来处理每个客户端的逻辑,然后在主服务器循环中为每个新连接启动一个 ClientHandler 线程。
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class MultiThreadServer {
// 使用线程池来管理客户端连接线程,避免为每个连接都创建一个新线程
private static final int THREAD_POOL_SIZE = 10;
private final ExecutorService threadPool = Executors.newFixedThreadPool(THREAD_POOL_SIZE);
public static void main(String[] args) {
new MultiThreadServer().start(12345);
}
public void start(int port) {
try (ServerSocket serverSocket = new ServerSocket(port)) {
System.out.println("多线程服务器已启动,正在监听端口 " + port + "...");
while (true) {
// 阻塞等待新客户端连接
Socket clientSocket = serverSocket.accept();
System.out.println("新客户端已连接: " + clientSocket.getInetAddress().getHostAddress());
// 为新客户端创建一个任务,并提交到线程池中执行
ClientHandler clientHandler = new ClientHandler(clientSocket);
threadPool.execute(clientHandler);
}
} catch (IOException e) {
System.err.println("服务器异常: " + e.getMessage());
e.printStackTrace();
} finally {
// 关闭线程池
threadPool.shutdown();
}
}
// 内部类,用于处理单个客户端的通信
private static class ClientHandler implements Runnable {
private final Socket clientSocket;
public ClientHandler(Socket socket) {
this.clientSocket = socket;
}
@Override
public void run() {
try (
BufferedReader in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
PrintWriter out = new PrintWriter(clientSocket.getOutputStream(), true)
) {
String inputLine;
while ((inputLine = in.readLine()) != null) {
System.out.println("[" + Thread.currentThread().getName() + "] 收到客户端消息: " + inputLine);
if ("exit".equalsIgnoreCase(inputLine)) {
System.out.println("客户端请求退出连接。");
break;
}
out.println("服务器回显: " + inputLine);
}
} catch (IOException e) {
// 如果客户端异常断开,这里会捕获到异常
System.err.println("处理客户端时发生错误: " + e.getMessage());
} finally {
try {
// 确保关闭客户端连接
if (clientSocket != null && !clientSocket.isClosed()) {
clientSocket.close();
}
System.out.println("客户端连接已关闭。");
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
}
代码解析
-
ExecutorService和Executors- 我们不再直接创建
Thread对象,而是使用ExecutorService(线程池)来管理任务。 Executors.newFixedThreadPool(THREAD_POOL_SIZE)创建了一个固定大小的线程池,这可以防止服务器因为海量客户端连接而耗尽系统资源。- 当有新连接时,我们创建一个
ClientHandler任务(实现了Runnable接口),然后调用threadPool.execute()将其提交给线程池执行。
- 我们不再直接创建
-
ClientHandler内部类- 这个类实现了
Runnable接口,它的run()方法包含了处理单个客户端通信的所有逻辑(读取、处理、发送)。 - 每个客户端连接都会在独立的线程中运行自己的
ClientHandler实例,互不干扰。
- 这个类实现了
-
finally块- 在
finally块中关闭clientSocket是一个非常好的实践,可以确保无论是否发生异常,客户端的连接资源都能被正确释放。
- 在
如何测试你的服务器
你需要一个客户端来连接服务器,你可以用 Java 写一个简单的客户端,或者使用任何网络工具,如 telnet 或 netcat。
使用 telnet 测试 (Windows)
- 启动服务器:运行上面的
MultiThreadServer.java。 - 打开命令提示符 (CMD)。
- 连接服务器:输入
telnet localhost 12345然后按回车。localhost是本地回环地址,代表你自己的电脑,如果你的服务器在另一台电脑上,请替换为服务器的 IP 地址。
- 发送消息:在
telnet窗口中输入任何文本,然后按回车,你应该能在服务器的控制台看到收到的消息,同时在telnet窗口看到服务器的回显。 - 断开连接:输入
exit然后按回车,客户端会断开连接。
一个简单的 Java 客户端示例
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.Socket;
import java.net.UnknownHostException;
public class SimpleClient {
public static void main(String[] args) {
String hostname = "localhost"; // 或服务器的IP地址
int port = 12345;
try (
// 创建一个 Socket 连接到指定的服务器和端口
Socket socket = new Socket(hostname, port);
// 获取输出流,用于向服务器发送数据
PrintWriter out = new PrintWriter(socket.getOutputStream(), true);
// 获取输入流,用于读取服务器的响应
BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
// 用于从控制台读取用户输入
BufferedReader stdIn = new BufferedReader(new InputStreamReader(System.in))
) {
System.out.println("已连接到服务器。");
System.out.println("请输入消息 (输入 'exit' 退出):");
String userInput;
// 循环读取用户输入
while ((userInput = stdIn.readLine()) != null) {
// 将用户输入发送给服务器
out.println(userInput);
// 如果用户输入 "exit",则退出循环
if ("exit".equalsIgnoreCase(userInput)) {
break;
}
// 读取并打印服务器的响应
String serverResponse = in.readLine();
System.out.println("服务器响应: " + serverResponse);
}
} catch (UnknownHostException e) {
System.err.println("不知道的主机: " + hostname);
e.printStackTrace();
} catch (IOException e) {
System.err.println("I/O 发生错误: " + e.getMessage());
e.printStackTrace();
}
System.out.println("客户端已关闭。");
}
}
更进一步的改进
- 处理对象序列化:上面的例子只能传输字符串,如果需要传输 Java 对象,可以使用
ObjectInputStream和ObjectOutputStream,对象必须实现Serializable接口。 - 使用 NIO (New I/O):对于高性能、高并发的服务器,传统的阻塞式 I/O 可能会成为瓶颈,Java NIO 提供了非阻塞 I/O 和选择器等特性,可以更高效地管理大量连接。
- 框架:在实际项目中,很少有人从零开始写 Socket 服务器,通常会使用成熟的网络框架,如 Netty、Mina 或 Spring Boot WebFlux (基于 Reactor),这些框架提供了更高级的抽象、更好的性能和更丰富的功能。
希望这个详细的指南能帮助你理解和使用 Java Socket 编程!
