4. TCP客户端编程与协议设计
TCP 是面向连接的字节流协议,它只保证“字节按序、可靠地到达”,并不保留应用层的消息边界。因此,客户端编程的核心不只是 new Socket(host, port),而是要在字节流之上设计一套双方都认可的应用层协议,并处理粘包、半包、超时与断线恢复。本节围绕 Socket 连接、请求响应格式、自定义协议、消息边界与基础异常恢复展开。
一、Socket 连接与基本读写
Java 中客户端通过 Socket 建立连接,连接成功后获得输入流与输出流。需要注意:Socket 的构造方法在连接失败时抛出 ConnectException,因此必须设置连接超时,避免线程被长时间阻塞。
Socket socket = new Socket();
socket.connect(new InetSocketAddress("127.0.0.1", 9000), 3000); // 连接超时 3s
socket.setSoTimeout(5000); // 读超时 5s
InputStream in = socket.getInputStream();
OutputStream out = socket.getOutputStream();
这里有两个容易混淆的超时:connect 的超时控制“建立连接”阶段,setSoTimeout 控制“读数据”阶段。写操作一般不会因对端不读而立即失败,但会因发送缓冲区满而阻塞,因此写超时通常通过 NIO 或独立线程处理。
二、请求响应格式与自定义应用层协议
TCP 本身没有“请求/响应”概念,这是应用层协议赋予的语义。一个最小可用的自定义协议通常包含:魔数、版本、消息类型、序列号、长度、负载。魔数用于快速识别非法连接,版本用于协议演进,序列号用于请求与响应配对,长度用于界定消息边界。
下面是一个 16 字节定长头部的示例协议:
| 字段 | 长度 | 说明 |
|---|---|---|
| magic | 2 字节 | 固定 0xCAFE,用于校验 |
| version | 1 字节 | 当前为 1 |
| type | 1 字节 | 1=请求,2=响应,3=心跳 |
| sequence | 4 字节 | 请求序号,响应原样返回 |
| bodyLength | 4 字节 | 负载字节数,最大 1MB |
| reserved | 4 字节 | 预留扩展 |
头部固定 16 字节,负载为 UTF-8 文本或二进制。接收方先读满 16 字节头部,解析出 bodyLength,再读满对应字节数的负载。这样就把“字节流”还原为“一条条消息”。
三、消息边界处理:粘包与半包
TCP 会把多次 write 合并发送(粘包),也可能把一次 write 拆成多次到达(半包)。因此不能假设“一次 read 就是一条完整消息”。正确做法是:先读头部,再按长度读负载,循环直到读满。
static byte[] readN(InputStream in, int n) throws IOException {
byte[] buf = new byte[n];
int off = 0;
while (off < n) {
int r = in.read(buf, off, n - off);
if (r == -1) throw new EOFException("连接已关闭");
off += r;
}
return buf;
}
这段 readN 是消息边界处理的关键:它不关心底层一次返回多少字节,只保证“读满 n 字节”或抛异常。配合头部中的 bodyLength,即可稳定地拆出一条条完整消息。
四、请求响应与序列号配对
客户端发送请求时生成递增的 sequence,服务端在响应中原样返回。客户端用一个 Map<Integer, CompletableFuture<Response>> 保存未完成的请求,收到响应后按序列号唤醒对应等待者。这样即使响应乱序到达,也能正确配对。
int seq = seqGen.incrementAndGet();
CompletableFuture<Response> future = new CompletableFuture<>();
pending.put(seq, future);
send(new Request(seq, "GET /time"));
Response resp = future.get(5, TimeUnit.SECONDS); // 等待响应
如果 5 秒内没有收到响应,future.get 抛出 TimeoutException,此时应从 pending 中移除该序列号,避免内存泄漏,并决定是否重试。
五、基础异常恢复
客户端常见的异常包括:连接被拒绝、读超时、对端关闭、协议头非法。处理策略如下:
- 连接失败:捕获
ConnectException,按退避策略重连,例如 1s、2s、4s,最多 3 次。 - 读超时:
SocketTimeoutException通常意味着请求丢失或服务端过载,可重发一次;若仍失败则上报。 - 对端关闭:
read返回 -1 或抛EOFException,应关闭旧连接并重建。 - 协议头非法:magic 不匹配或 bodyLength 超过上限,立即关闭连接,防止被恶意长度拖垮内存。
六、一个可复用的客户端骨架
把连接、读写、序列号、超时、重连封装到一个类中,业务层只调用 request(String) 并拿到响应字符串。核心循环是:确保连接可用 → 写请求 → 等待 future → 超时或异常时清理并重连。这样既保持了协议的可扩展性,也让异常恢复有统一的入口。
掌握以上内容后,客户端就不再是“能连上就行”的演示代码,而是具备明确消息边界、可配对请求响应、可处理超时与断线的工程实现。后续在服务端章节中,会看到同一套协议在服务端的解析与回写方式。