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"))