3. Socket 编程

概念

Socket 是进程使用传输层的端点。本课例程使用 TCP:服务器 bind、listen、accept,客户端 connect。accept 返回的是这条连接自己的套接字,监听套接字继续等下一个客户。 短连接做完一次请求就关闭,实现简单,但每次都要三次握手。长连接在同一条 TCP 上连续收发多条应用消息,适合 RPC 和推送。长连接必须能分出消息边界,并且要发现对端已经悄悄断开。 心跳是长连接上的小消息。客户端定期发送 PING,服务器回答 PONG。一段时间内没有任何字节到达,就认为连接失效并关闭。

  • 短连接:connect → 一次请求与应答 → close。HTTP/1.0 默认就是这种用法。
  • 长连接:连接保持打开,用换行或长度前缀区分每一条消息。
  • 心跳:应用层 PING/PONG。只依赖 TCP keepalive 往往太慢,业务超时要自己定。
  • read 一次不一定读满一条消息,服务端要循环读到约定的边界。

算法流程

下面的 UML 图说明 Java 例程和 Python 例程共同实现的流程。实线箭头表示调用或发送,虚线箭头表示返回或唤起。

短连接(UML 时序图)

sequenceDiagram participant Client as 客户端 participant Listen as 监听套接字 participant Conn as 连接套接字 Client->>Listen: connect,完成三次握手 Listen->>Conn: accept,得到这条连接 Client->>Conn: 一条请求,以换行结束 Conn-->>Client: 一条应答 Client->>Conn: close

长连接与心跳(UML 时序图)

sequenceDiagram participant Client as 客户端 participant Server as 服务器 Client->>Server: connect,连接保持打开 Client->>Server: PING Server-->>Client: PONG Client->>Server: ECHO hello Server-->>Client: OK ECHO hello Client->>Server: QUIT Note over Client,Server: 一段时间内没有任何字节,就判定连接失效并关闭

Java 例程

import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;

public class SocketDemo {
    /** 短连接:三次握手之后只做一次请求,然后立刻关闭。 */
    static void shortClient(String host, int port) throws Exception {
        Socket s = new Socket(host, port);       // 构造函数内部完成 connect
        // autoFlush=true:println 后立刻把缓冲区推到网络上
        PrintWriter out = new PrintWriter(new OutputStreamWriter(s.getOutputStream(), StandardCharsets.UTF_8), true);
        BufferedReader in = new BufferedReader(new InputStreamReader(s.getInputStream(), StandardCharsets.UTF_8));
        out.println("TIME");                     // 应用协议用换行作为一条消息的边界
        System.out.println(in.readLine());       // 读到换行才返回;对端不发就一直等
        s.close();                               // 短连接在这一次应答后结束
    }

    /** 长连接:同一条 TCP 上连续发送心跳和业务命令。 */
    static void longClient(String host, int port) throws Exception {
        Socket s = new Socket(host, port);
        s.setSoTimeout(15000);                   // 15 秒没有任何字节到达就抛超时,用来发现死连接
        PrintWriter out = new PrintWriter(new OutputStreamWriter(s.getOutputStream(), StandardCharsets.UTF_8), true);
        BufferedReader in = new BufferedReader(new InputStreamReader(s.getInputStream(), StandardCharsets.UTF_8));
        out.println("PING");                     // 心跳:问对端是否还活着
        System.out.println(in.readLine());       // 期望回答 PONG
        out.println("ECHO hello");               // 心跳之后继续用同一条连接发业务
        System.out.println(in.readLine());
        out.println("QUIT");                     // 约定的结束命令,让服务器退出读循环
        s.close();
    }

    public static void main(String[] args) throws Exception {
        ServerSocket server = new ServerSocket(9000); // bind + listen,等待客户端
        Socket client = server.accept();              // 返回这条连接自己的套接字,监听套接字继续留给下一个客户
        client.setSoTimeout(15000);                   // 超时未收到心跳或业务数据,就认为连接失效
        BufferedReader in = new BufferedReader(new InputStreamReader(client.getInputStream(), StandardCharsets.UTF_8));
        PrintWriter out = new PrintWriter(new OutputStreamWriter(client.getOutputStream(), StandardCharsets.UTF_8), true);
        String line;
        while ((line = in.readLine()) != null) {      // null 表示对端已关闭输出
            if ("PING".equals(line)) out.println("PONG");
            else if ("QUIT".equals(line)) break;
            else out.println("OK " + line);           // 其他命令原样确认
        }
        client.close();
        server.close();
    }
}

Python 例程

import socket

def short_client(host, port):
    # create_connection 相当于 socket() + connect(),timeout 是这次连接和后续收发的时限
    s = socket.create_connection((host, port), timeout=5)
    s.sendall(b"TIME\n")                 # sendall 会循环发送,直到这几个字节都交给内核
    print(s.recv(1024).decode("utf-8").strip())  # 短连接只收一次应答
    s.close()                            # 请求结束就关闭,下次再重新握手

def long_client(host, port):
    s = socket.create_connection((host, port), timeout=15)
    # 同一条连接上先心跳,再发业务,最后通知服务器退出
    for line in (b"PING\n", b"ECHO hello\n", b"QUIT\n"):
        s.sendall(line)
        if line != b"QUIT\n":
            print(s.recv(1024).decode("utf-8").strip())
    s.close()

def serve(port):
    server = socket.socket()             # 默认是 AF_INET + SOCK_STREAM,也就是 TCP
    server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)  # 进程重启后可以立刻再绑定同一端口
    server.bind(("127.0.0.1", port))     # 只接受本机连接
    server.listen(8)                     # 已完成握手、还没被 accept 的连接最多排 8 个
    conn, _ = server.accept()            # conn 才是和这个客户端通信的套接字
    conn.settimeout(15)                  # 15 秒无数据视为心跳丢失
    buf = b""                            # TCP 是字节流,一次 recv 不一定刚好是一行
    while True:
        data = conn.recv(1024)           # 返回空字节串表示对端已关闭
        if not data:
            break
        buf += data
        while b"\n" in buf:              # 缓冲区里可能已经有多行,要逐条取走
            line, buf = buf.split(b"\n", 1)
            text = line.decode("utf-8")
            if text == "PING":
                conn.sendall(b"PONG\n")  # 回答心跳
            elif text == "QUIT":
                conn.close()
                server.close()
                return
            else:
                conn.sendall(("OK " + text + "\n").encode("utf-8"))