“点对点”(Peer-to-Peer, P2P)在 Socket 编程中通常指的是两个独立的程序(客户端和服务器)直接建立连接,进行双向数据交换,这与“客户端-服务器”模型非常相似,因为本质上任何 TCP 通信都是两个端点之间的直接连接。

我们将分步实现一个简单的 P2P 聊天程序,它包含两个部分:
- 服务器端:监听来自客户端的连接请求。
- 客户端:主动连接服务器,并与服务器进行双向通信。
核心概念回顾
在开始编码前,快速回顾一下几个关键的 Java Socket 类:
java.net.ServerSocket:服务器端使用,它监听指定的端口,等待客户端的连接请求,当一个客户端请求连接时,ServerSocket会创建一个新的Socket对象来代表这个连接。java.net.Socket:客户端和服务器端都使用,它代表一个网络连接的两端,通过Socket,我们可以获取输入流 (InputStream) 来读取数据,和输出流 (OutputStream) 来发送数据。java.io.InputStream/java.io.OutputStream:用于在连接上传输原始字节。java.io.BufferedReader/java.io.PrintWriter:为了方便处理文本数据,我们通常将字节流包装成字符流。BufferedReader可以高效地读取一行文本,PrintWriter可以方便地打印各种数据类型并自动处理换行符。
项目结构
我们将创建两个独立的 Java 文件:
P2P-Chat/
├── Server.java
└── Client.java
第一步:实现服务器端
服务器端的工作流程是:
- 在指定端口上创建一个
ServerSocket并开始监听。 - 等待客户端连接,这是一个阻塞方法,直到有客户端连接进来。
- 一旦客户端连接,获取与该客户端通信的
Socket。 - 为这个
Socket创建输入流和输出流。 - 在一个无限循环中,不断读取客户端发来的消息,并回显给客户端。
- 如果客户端关闭连接,则捕获异常并关闭资源。
Server.java
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 Server {
public static void main(String[] args) {
int port = 12345; // 服务器监听的端口号
try (ServerSocket serverSocket = new ServerSocket(port)) {
System.out.println("服务器已启动,正在监听端口 " + port + "...");
// 等待客户端连接,这是一个阻塞方法
Socket clientSocket = serverSocket.accept();
System.out.println("客户端已连接: " + clientSocket.getInetAddress().getHostAddress());
// 为客户端连接创建输入流和输出流
PrintWriter out = new PrintWriter(clientSocket.getOutputStream(), true);
BufferedReader in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
String inputLine;
// 读取客户端发送的数据
while ((inputLine = in.readLine()) != null) {
System.out.println("客户端说: " + inputLine);
// 如果客户端发送 "bye",则结束通信
if ("bye".equalsIgnoreCase(inputLine)) {
System.out.println("客户端请求断开连接。");
break;
}
// 向客户端回送消息
out.println("服务器收到: " + inputLine);
}
} catch (IOException e) {
System.out.println("服务器异常: " + e.getMessage());
e.printStackTrace();
}
System.out.println("服务器已关闭。");
}
}
第二步:实现客户端
客户端的工作流程是:
- 知道服务器的 IP 地址和端口号。
- 创建一个
Socket并尝试连接到服务器,这也是一个阻塞方法,直到连接成功或失败。 - 连接成功后,获取与服务器通信的
Socket。 - 为这个
Socket创建输入流和输出流。 - 启动一个单独的线程来持续监听服务器发来的消息,这样就不会阻塞主线程发送消息。
- 在主线程中,通过控制台读取用户输入,并发送给服务器。
- 如果用户输入 "bye",则关闭连接和资源。
Client.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 Client {
public static void main(String[] args) {
String hostname = "localhost"; // 服务器地址,如果是本地则为 "localhost"
int port = 12345; // 服务器端口号
try (Socket socket = new Socket(hostname, port)) {
System.out.println("已成功连接到服务器 " + hostname + ":" + port);
// 为服务器连接创建输入流和输出流
PrintWriter out = new PrintWriter(socket.getOutputStream(), true);
BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
// --- 启动一个新线程来接收服务器的消息 ---
// 这样可以避免在接收消息时阻塞发送消息
Thread receiveThread = new Thread(new Runnable() {
@Override
public void run() {
try {
String serverResponse;
while ((serverResponse = in.readLine()) != null) {
System.out.println("服务器回复: " + serverResponse);
}
} catch (IOException e) {
// 当服务器关闭连接时,in.readLine() 会返回 null,这里会抛出 IOException
System.out.println("与服务器连接已断开。");
}
}
});
receiveThread.start();
// --- 主线程用于发送消息 ---
BufferedReader stdIn = new BufferedReader(new InputStreamReader(System.in));
String userInput;
System.out.println("请输入消息 (输入 'bye' 退出):");
while ((userInput = stdIn.readLine()) != null) {
out.println(userInput);
if ("bye".equalsIgnoreCase(userInput)) {
break;
}
}
} catch (UnknownHostException e) {
System.err.println("无法找到主机: " + hostname);
e.printStackTrace();
} catch (IOException e) {
System.err.println("无法连接到 " + hostname + ":" + port);
e.printStackTrace();
}
System.out.println("客户端已关闭。");
}
}
第三步:如何运行
-
编译代码:打开终端或命令提示符,进入
P2P-Chat目录,运行:javac Server.java Client.java
-
运行服务器:首先启动服务器程序,它会在后台等待连接。
java Server
你会看到输出:
服务器已启动,正在监听端口 12345... -
运行客户端:打开一个新的终端窗口,启动客户端程序。
java Client
你会看到输出:
已成功连接到服务器 localhost:12345 请输入消息 (输入 'bye' 退出): -
开始聊天:
- 在客户端的终端输入
你好,服务器!,然后按回车。 - 在服务器的终端,你会看到:
客户端已连接: 127.0.0.1 客户端说: 你好,服务器! - 在客户端的终端,你会看到服务器的回复:
服务器回复: 服务器收到: 你好,服务器! - 你可以在服务器端输入回复(修改
Server.java代码,让它也能从控制台读取并发送),或者让客户端继续发送消息。
- 在客户端的终端输入
-
结束会话:在客户端输入
bye并按回车。客户端和服务器都会检测到 "bye" 消息,关闭连接,并打印各自的关闭信息。
代码讲解与注意事项
-
双向通信与多线程:
- 在
Client.java中,我们使用了多线程,这是实现双向通信的关键,一个线程负责发送(主线程),另一个线程负责接收(receiveThread),如果只用一个线程,比如先发送后接收,那么程序在执行到in.readLine()时会一直等待,导致无法发送新的消息。 Server.java的例子是同步的,它一次只处理一个客户端,并且每次收到消息后才回复,如果要支持多个客户端,需要为每个连接创建一个新线程(或使用线程池)。
- 在
-
资源管理:
- 我们使用了
try-with-resources语句(try (Socket ...)),这是一个非常推荐的做法,它能确保Socket以及相关的InputStream和OutputStream在代码块执行完毕后被自动关闭,即使发生了异常。
- 我们使用了
-
readLine()的阻塞特性:BufferedReader.readLine()方法会一直等待,直到它从流中读取到一行完整的文本(以换行符\n或回车换行\r\n,如果另一端关闭了连接,readLine()会返回null,如果网络中断,它会抛出IOException。
-
PrintWriter的autoFlush:- 在创建
PrintWriter时,我们传入了true作为第二个参数:new PrintWriter(socket.getOutputStream(), true),这开启了自动刷新功能,当你调用println()方法时,输出缓冲区会自动刷新,确保数据被立即发送出去,这对于交互式应用非常重要。
- 在创建
进阶:真正的 P2P(Peer-to-Peer)
上面的例子是经典的“客户端-服务器”模型,在真正的 P2P 网络中,每个节点既是客户端也是服务器,要实现这一点,你需要:
- 每个节点都启动一个
ServerSocket,以便其他节点可以主动连接它。 - 需要一个“发现机制”,当一个节点想要连接另一个节点时,它必须知道对方的 IP 地址和端口号,这可以通过一个中央服务器(Tracker)来维护所有在线节点的列表,或者通过广播/组播等技术来实现。
这个简单的例子已经为你打下了坚实的基础,理解了这个模型,你就可以进一步构建更复杂的分布式应用了。
