1. 概述

Socket 编程指的是在多台计算机上运行的程序之间进行通信,这些设备通过网络相互连接。Socket 编程主要使用两种通信协议:**用户数据报协议 (UDP) 和传输控制协议 (TCP)**。

两者的核心区别在于:

  • UDP 是无连接的:客户端和服务器之间没有会话
  • TCP 是面向连接的:必须先在客户端和服务器之间建立专用连接才能通信

本文将重点介绍 TCP/IP 网络上的 Socket 编程基础,并演示如何用 Java 编写客户端/服务器应用程序。UDP 不是主流协议,实际开发中较少遇到。

2. 项目准备

Java 提供了一系列类和接口来处理客户端和服务器之间的底层通信细节,这些主要位于 java.net 包中:

import java.net.*;

我们还需要 java.io 包来处理输入输出流:

import java.io.*;

为简化演示,我们将在同一台计算机上运行客户端和服务器程序。如果在不同的网络计算机上运行,只需修改 IP 地址即可。这里我们使用本地地址 127.0.0.1(localhost)。

3. 简单示例

让我们通过一个最基础的客户端/服务器示例来上手。这是一个双向通信应用:客户端向服务器发送问候,服务器做出响应。

创建服务器类 GreetServer.java

public class GreetServer {
    private ServerSocket serverSocket;
    private Socket clientSocket;
    private PrintWriter out;
    private BufferedReader in;

    public void start(int port) throws IOException {
        serverSocket = new ServerSocket(port);
        clientSocket = serverSocket.accept();
        out = new PrintWriter(clientSocket.getOutputStream(), true);
        in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
        String greeting = in.readLine();
            if ("hello server".equals(greeting)) {
                out.println("hello client");
            }
            else {
                out.println("unrecognised greeting");
            }
    }

    public void stop() throws IOException {
        in.close();
        out.close();
        clientSocket.close();
        serverSocket.close();
    }
    
    public static void main(String[] args) throws IOException {
        GreetServer server = new GreetServer();
        server.start(6666);
    }
}

创建客户端类 GreetClient.java

public class GreetClient {
    private Socket clientSocket;
    private PrintWriter out;
    private BufferedReader in;

    public void startConnection(String ip, int port) throws IOException {
        clientSocket = new Socket(ip, port);
        out = new PrintWriter(clientSocket.getOutputStream(), true);
        in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
    }

    public String sendMessage(String msg) throws IOException {
        out.println(msg);
        return in.readLine();
    }

    public void stopConnection() throws IOException {
        in.close();
        out.close();
        clientSocket.close();
    }
}

启动服务器:在 IDE 中直接运行 GreetServer 的 main 方法。

然后通过单元测试向服务器发送问候消息,验证服务器响应:

@Test
public void givenGreetingClient_whenServerRespondsWhenStarted_thenCorrect() throws IOException {
    GreetClient client = new GreetClient();
    client.startConnection("127.0.0.1", 6666);
    String response = client.sendMessage("hello server");
    assertEquals("hello client", response);
}

这个示例让我们对后续内容有了基本预期。接下来我们将通过这个简单示例拆解 Socket 通信机制,并深入更复杂的场景。

4. Socket 工作原理

我们用上述示例逐步解析 Socket 通信的不同部分。

4.1. 服务器端

服务器通常在网络中的特定计算机上运行,其 Socket 绑定到特定端口号。本例中我们使用同一台计算机,并在端口 6666 启动服务器:

ServerSocket serverSocket = new ServerSocket(6666);

服务器等待并监听 Socket,直到客户端发起连接请求。这通过以下代码实现:

Socket clientSocket = serverSocket.accept();

当服务器代码执行到 accept 方法时,会阻塞线程,直到有客户端发起连接请求。

连接成功建立后,服务器获得一个新 Socket clientSocket,它绑定到相同的本地端口 6666,并设置远程端点为客户端的地址和端口。

此时新的 Socket 对象使服务器与客户端直接建立连接。我们可以通过获取输出流和输入流来与客户端交换消息:

PrintWriter out = new PrintWriter(clientSocket.getOutputStream(), true);
BufferedReader in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));

现在服务器可以与客户端无限期交换消息,直到 Socket 及其流被关闭。

但在我们的示例中,服务器在发送一次响应后就关闭了连接。这意味着如果再次运行测试,服务器会拒绝连接。

要实现持续通信,我们需要在 while 循环中读取输入流,并在客户端发送终止请求时退出。这将在下一节演示。

对于每个新客户端,服务器都需要 accept 方法返回的新 Socket。我们使用 serverSocket 继续监听连接请求,同时处理已连接客户端的需求。在第一个示例中尚未实现此功能。

4.2. 客户端

客户端必须知道服务器运行的计算机主机名或 IP,以及服务器监听的端口号。

发起连接请求时,客户端尝试在服务器的机器和端口上与服务器会合:

Socket clientSocket = new Socket("127.0.0.1", 6666);

客户端还需要向服务器标识自己,因此它会绑定到系统分配的本地端口号(我们无需手动处理)。

上述构造函数只有在服务器接受连接后才会创建新 Socket,否则会抛出连接拒绝异常。成功创建后,我们可以获取输入输出流与服务器通信:

PrintWriter out = new PrintWriter(clientSocket.getOutputStream(), true);
BufferedReader in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));

客户端的输入流连接到服务器的输出流,服务器的输入流也连接到客户端的输出流。

5. 持续通信

当前服务器在客户端连接时阻塞,然后在等待客户端消息时再次阻塞。收到单条消息后,由于未处理持续通信,服务器会关闭连接。

这种设计仅适用于 ping 请求。但如果我们想实现聊天服务器,就需要服务器和客户端之间的持续双向通信。

我们需要创建一个 while 循环持续监听服务器输入流中的传入消息。

创建新服务器 EchoServer.java,其唯一功能是将收到的客户端消息原样返回:

public class EchoServer {
    private ServerSocket serverSocket;
    private Socket clientSocket;
    private PrintWriter out;
    private BufferedReader in;

    public void start(int port) throws IOException {
        serverSocket = new ServerSocket(port);
        clientSocket = serverSocket.accept();
        out = new PrintWriter(clientSocket.getOutputStream(), true);
        in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
        
        String inputLine;
        while ((inputLine = in.readLine()) != null) {
            if (".".equals(inputLine)) {
                out.println("good bye");
                break;
            }
            out.println(inputLine);
        }
    }
    
    public void stop() throws IOException {
        in.close();
        out.close();
        clientSocket.close();
        serverSocket.close();
    }
}

注意我们添加了终止条件:当收到句点字符 . 时退出循环。

使用 main 方法启动 EchoServer(与 GreetServer 类似),但使用不同端口(如 4444)避免混淆。

EchoClientGreetClient 类似,为清晰起见我们分开实现。

在测试类中创建 setup 方法初始化服务器连接:

@Before
public void setup() throws IOException {
    client = new EchoClient();
    client.startConnection("127.0.0.1", 4444);
}

创建 tearDown 方法释放资源(使用网络资源时的最佳实践):

@After
public void tearDown() throws IOException {
    client.stopConnection();
}

测试回显服务器:

@Test
public void givenClient_whenServerEchosMessage_thenCorrect() throws IOException {
    String resp1 = client.sendMessage("hello");
    String resp2 = client.sendMessage("world");
    String resp3 = client.sendMessage("!");
    String resp4 = client.sendMessage(".");
    
    assertEquals("hello", resp1);
    assertEquals("world", resp2);
    assertEquals("!", resp3);
    assertEquals("good bye", resp4);
}

这比初始示例有改进:现在我们发送终止信号告诉服务器会话何时结束,而不是在单次通信后关闭连接。

6. 多客户端服务器

尽管前一个示例比第一个有所改进,但仍不是理想方案。服务器必须能够同时服务多个客户端和多个请求。

本节将重点处理多客户端场景

另一个新特性是:同一客户端可以断开连接后重新连接,而不会在服务器上遇到连接拒绝异常或连接重置。这在之前的实现中无法做到。

这意味着我们的服务器在处理来自多个客户端的多个请求时将更加健壮和稳定。

我们将为每个新客户端创建新 Socket,并在不同线程中处理该客户端的请求。同时服务的客户端数量等于运行的线程数。

主线程将在 while 循环中运行,持续监听新连接。

创建新服务器 EchoMultiServer.java,在其中创建处理线程类管理每个客户端的 Socket 通信:

public class EchoMultiServer {
    private ServerSocket serverSocket;

    public void start(int port) throws IOException {
        serverSocket = new ServerSocket(port);
        while (true) {
            new EchoClientHandler(serverSocket.accept()).start();
        }
    }

    public void stop() throws IOException {
        serverSocket.close();
    }

    private static class EchoClientHandler extends Thread {
        private Socket clientSocket;
        private PrintWriter out;
        private BufferedReader in;

        public EchoClientHandler(Socket socket) {
            this.clientSocket = socket;
        }

        public void run() {
            try {
                out = new PrintWriter(clientSocket.getOutputStream(), true);
                in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
                
                String inputLine;
                while ((inputLine = in.readLine()) != null) {
                    if (".".equals(inputLine)) {
                        out.println("bye");
                        break;
                    }
                    out.println(inputLine);
                }
                
                in.close();
                out.close();
                clientSocket.close();
            } catch (IOException e) {
                // 处理异常
            }
        }
    }
}

注意现在我们在 while 循环中调用 accept。每次循环执行时,accept 调用会阻塞直到新客户端连接。然后为该客户端创建处理线程 EchoClientHandler

线程内部的操作与 EchoServer(仅处理单个客户端)相同。EchoMultiServer 将工作委托给 EchoClientHandler,以便在 while 循环中继续监听更多客户端。

仍使用 EchoClient 测试服务器。这次创建多个客户端,每个客户端与服务器收发多条消息。

使用 main 方法在端口 5555 启动服务器。

为清晰起见,在新测试套件中编写测试:

@Test
public void givenClient1_whenServerResponds_thenCorrect() throws IOException {
    EchoClient client1 = new EchoClient();
    client1.startConnection("127.0.0.1", 5555);
    String msg1 = client1.sendMessage("hello");
    String msg2 = client1.sendMessage("world");
    String terminate = client1.sendMessage(".");
    
    assertEquals(msg1, "hello");
    assertEquals(msg2, "world");
    assertEquals(terminate, "bye");
}

@Test
public void givenClient2_whenServerResponds_thenCorrect() throws IOException {
    EchoClient client2 = new EchoClient();
    client2.startConnection("127.0.0.1", 5555);
    String msg1 = client2.sendMessage("hello");
    String msg2 = client2.sendMessage("world");
    String terminate = client2.sendMessage(".");
    
    assertEquals(msg1, "hello");
    assertEquals(msg2, "world");
    assertEquals(terminate, "bye");
}

可以创建任意多个此类测试用例,每个测试生成新客户端,服务器都能处理所有请求。

7. 总结

本文重点介绍了 TCP/IP 网络上的 Socket 编程基础,并用 Java 编写了简单的客户端/服务器应用程序。

完整源代码可在 GitHub 项目 中找到。


原始标题:A Guide to Java Sockets