杰瑞科技汇

Java TCP Socket编程如何实现可靠通信?

核心概念与流程

TCP 通信就像打电话,必须先建立连接,然后才能互相通话,最后挂断连接。

Java TCP Socket编程如何实现可靠通信?-图1
(图片来源网络,侵删)

两个核心角色

  • 客户端:主动发起连接请求的一方,它需要知道服务器的 IP 地址和端口号。
  • 服务器端:被动等待连接请求的一方,它需要绑定一个特定的端口号,并在此端口上监听客户端的连接。

通信流程

一个完整的 TCP 通信过程如下:

  1. 服务器端启动

    • 创建一个 ServerSocket 实例,并绑定到一个具体的端口号。
    • 调用 ServerSocketaccept() 方法,该方法会阻塞线程,等待客户端的连接请求。
    • 当一个客户端连接成功时,accept() 方法返回一个新的 Socket 实例,这个 Socket 代表了与该客户端的专用连接通道。
  2. 客户端启动

    • 创建一个 Socket 实例,同时指定服务器的 IP 地址和端口号。
    • Java 会尝试通过三次握手与服务器建立连接,如果成功,Socket 构造函数会返回;如果失败(如服务器未启动或端口错误),则会抛出异常。
  3. 数据传输

    Java TCP Socket编程如何实现可靠通信?-图2
    (图片来源网络,侵删)
    • 一旦连接建立,客户端和服务器端的 Socket 对象就可以通过 输入流输出流 进行双向数据传输。
    • 客户端通过 Socket.getOutputStream() 获取输出流,向服务器发送数据。
    • 客户端通过 Socket.getInputStream() 获取输入流,读取服务器发来的数据。
    • 服务器端通过 Socket.getOutputStream() 获取输出流,向客户端发送数据。
    • 服务器端通过 Socket.getInputStream() 获取输入流,读取客户端发来的数据。
  4. 关闭连接

    通信结束后,客户端和服务器端都需要关闭各自的 Socket 和相关的输入/输出流,关闭的顺序通常是:先关闭流,再关闭 Socket。


代码示例

下面是一个经典的“Echo Server”(回显服务器)的完整实现,客户端发送任何消息,服务器都会原样返回。

服务器端代码

// EchoServer.java
import java.io.*;
import java.net.*;
public class EchoServer {
    public static void main(String[] args) {
        // 定义服务器监听的端口号
        int port = 8888;
        try ( // 使用 try-with-resources 语句,确保资源自动关闭
              // 1. 创建 ServerSocket,并绑定到指定端口
              ServerSocket serverSocket = new ServerSocket(port)) {
            System.out.println("服务器已启动,正在监听端口 " + port + "...");
            // 2. 调用 accept() 方法,阻塞等待客户端连接
            // 当有客户端连接时,accept() 返回一个 Socket 对象,代表与该客户端的连接
            Socket clientSocket = serverSocket.accept();
            System.out.println("客户端已连接: " + clientSocket.getInetAddress().getHostAddress());
            try (
                 // 3. 获取客户端的输入流,用于读取客户端发送的数据
                 InputStream inputStream = clientSocket.getInputStream();
                 BufferedReader in = new BufferedReader(new InputStreamReader(inputStream));
                 // 4. 获取客户端的输出流,用于向客户端发送数据
                 OutputStream outputStream = clientSocket.getOutputStream();
                 PrintWriter out = new PrintWriter(outputStream, true)) { // autoFlush=true
                String inputLine;
                // 5. 循环读取客户端发送的数据
                while ((inputLine = in.readLine()) != null) {
                    System.out.println("收到客户端消息: " + inputLine);
                    // 6. 将收到的消息回显给客户端
                    out.println("服务器回显: " + inputLine);
                }
                System.out.println("客户端已断开连接。");
            } // 内部的 try-with-resources 会自动关闭 in 和 out
            // clientSocket 会在外层 try-with-resources 结束时关闭
        } catch (IOException e) {
            System.err.println("服务器异常: " + e.getMessage());
            e.printStackTrace();
        }
    }
}

客户端代码

// EchoClient.java
import java.io.*;
import java.net.*;
public class EchoClient {
    public static void main(String[] args) {
        // 服务器的 IP 地址(本地回环地址)和端口号
        String host = "127.0.0.1";
        int port = 8888;
        try ( // 使用 try-with-resources 语句,确保资源自动关闭
              // 1. 创建 Socket 对象,尝试连接到服务器
              Socket socket = new Socket(host, port);
              // 2. 获取服务器的输出流,用于向服务器发送数据
              OutputStream outputStream = socket.getOutputStream();
              PrintWriter out = new PrintWriter(outputStream, true); // autoFlush=true
              // 3. 获取服务器的输入流,用于读取服务器返回的数据
              InputStream inputStream = socket.getInputStream();
              BufferedReader in = new BufferedReader(new InputStreamReader(inputStream))) {
            System.out.println("已连接到服务器 " + host + ":" + port);
            // 创建一个控制台读取器,用于从键盘读取用户输入
            BufferedReader stdIn = new BufferedReader(new InputStreamReader(System.in));
            String userInput;
            // 4. 循环读取用户输入并发送给服务器
            System.out.println("请输入要发送的消息 (输入 'exit' 退出):");
            while ((userInput = stdIn.readLine()) != null) {
                if ("exit".equalsIgnoreCase(userInput)) {
                    break; // 输入 'exit' 则退出循环
                }
                // 发送消息到服务器
                out.println(userInput);
                // 读取并打印服务器回显的消息
                String response = in.readLine();
                System.out.println("服务器响应: " + response);
            }
        } catch (UnknownHostException e) {
            System.err.println("无法找到主机: " + host);
            e.printStackTrace();
        } catch (IOException e) {
            System.err.println("I/O 发生异常: " + e.getMessage());
            e.printStackTrace();
        }
    }
}

关键点解析

try-with-resources 语句

在上述代码中,我们使用了 try-with-resources (Java 7+ 特性),只要一个类实现了 AutoCloseable 接口(Socket, ServerSocket, InputStream, OutputStream, Reader, Writer 等都实现了),就可以放在 try 后面的括号里,这样,无论 try 块是正常结束还是抛出异常,JVM 都会自动调用这些资源的 close() 方法,避免了资源泄漏。

Java TCP Socket编程如何实现可靠通信?-图3
(图片来源网络,侵删)

InputStream / OutputStream vs. Reader / Writer

  • 字节流 (InputStream/OutputStream):处理原始的二进制数据(图片、视频、文件等),它们是面向字节的。
  • 字符流 (Reader/Writer):处理字符数据(文本),它们是面向字符的,并且可以处理字符编码。

在 TCP Socket 编程中,底层传输的是字节流,当我们想传输文本时,通常需要进行“桥接”:

  • InputStreamReaderInputStream (字节流) 包装成 Reader (字符流)。
  • OutputStreamWriterOutputStream (字节流) 包装成 Writer (字符流)。

为了方便操作文本行,我们通常会进一步使用 BufferedReaderPrintWriter

阻塞方法

  • ServerSocket.accept()服务器端阻塞,它会一直等待,直到有客户端连接。
  • Socket.getInputStream().read()客户端和服务器端都阻塞,它会一直等待,直到从网络中读取到数据。
  • BufferedReader.readLine()阻塞,它会一直等待,直到读取到一行完整的文本(以 \n, \r, 或 \r\n

理解这些阻塞行为对于编写多线程网络程序至关重要。

半关闭

TCP 连接是全双工的,意味着可以双向独立关闭。Socket.close() 会同时关闭输入流和输出流,但有时我们可能只想关闭一个方向,

  • 客户端发送完所有数据后,想告诉服务器“我没有数据要发了,但你还可以继续发给我”,这时可以调用 socket.shutdownOutput()
  • 服务器收到这个信号后,在 read() 方法时会读到 -1,从而知道客户端已经发送完毕。

进阶与最佳实践

处理多个客户端

上面的服务器一次只能处理一个客户端,因为它在 accept() 之后是同步等待数据收发的,在实际应用中,服务器需要能够同时为多个客户端服务,这通常通过多线程实现。

改进的服务器端(多线程版)

// MultiThreadEchoServer.java
import java.io.*;
import java.net.*;
public class MultiThreadEchoServer {
    public static void main(String[] args) {
        int port = 8888;
        try (ServerSocket serverSocket = new ServerSocket(port)) {
            System.out.println("多线程服务器已启动,正在监听端口 " + port + "...");
            while (true) { // 循环接受所有客户端连接
                Socket clientSocket = serverSocket.accept();
                System.out.println("新客户端已连接: " + clientSocket.getInetAddress().getHostAddress());
                // 为每个客户端创建一个新的线程来处理
                ClientHandler handler = new ClientHandler(clientSocket);
                new Thread(handler).start();
            }
        } catch (IOException e) {
            System.err.println("服务器异常: " + e.getMessage());
        }
    }
}
// 客户端处理任务
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);
                out.println("服务器回显: " + inputLine);
            }
        } catch (IOException e) {
            // 如果客户端断开连接,会抛出 SocketException,这是正常行为
            System.out.println("客户端 " + clientSocket.getInetAddress() + " 已断开连接。");
        } finally {
            try {
                clientSocket.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

使用 NIO (New I/O) 提高性能

对于高并发的场景,为每个连接创建一个线程会消耗大量系统资源,Java NIO (Non-blocking I/O) 提供了一种更高效的方式,它使用 通道选择器,允许一个线程管理多个连接,通过事件驱动模型来处理 I/O 操作,从而大大提高了系统的吞吐量。

NIO 是一个更复杂但更强大的主题,适合构建高性能的网络框架。

序列化对象

如果不想只传输字符串,而是想直接传输 Java 对象,可以使用 ObjectInputStreamObjectOutputStream,被传输的类必须实现 java.io.Serializable 接口。

// 发送对象
ObjectOutputStream oos = new ObjectOutputStream(socket.getOutputStream());
oos.writeObject(myObject);
// 接收对象
ObjectInputStream ois = new ObjectInputStream(socket.getInputStream());
MyObject myObject = (MyObject) ois.readObject();

使用现成框架

在实际项目中,直接使用原生 Socket 编写复杂的网络应用是比较繁琐的,通常会使用成熟的网络框架,它们封装了底层的细节,提供了更高级的功能,如:

  • Netty: 异步事件驱动的网络应用框架,性能极高,是目前最流行的 Java NIO 框架。
  • Mina: 另一个流行的 NIO 框架。
  • Apache Commons Net: 提供了一些客户端和服务器端的实现,相对简单。

希望这份详细的指南能帮助你理解 Java TCP Socket 编程!

分享:
扫描分享到社交APP
上一篇
下一篇