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 字节定长头部的示例协议:

字段长度说明
magic2 字节固定 0xCAFE,用于校验
version1 字节当前为 1
type1 字节1=请求,2=响应,3=心跳
sequence4 字节请求序号,响应原样返回
bodyLength4 字节负载字节数,最大 1MB
reserved4 字节预留扩展

头部固定 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,即可稳定地拆出一条条完整消息。

TCP 字节流到应用层消息的边界还原 发送方 write Msg1 Msg2 Msg3 TCP 字节流(无边界) Msg1 Msg2 Msg3 可能合并到达,也可能被拆分 接收方 readN Msg1 Msg2 Msg3 16 字节定长头部 + 变长负载 magic 2B ver 1B type 1B sequence 4B bodyLength 4B reserved 4B body(变长) UTF-8 / 二进制 先读满 16B 头部 → 解析 bodyLength → 再读满 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 → 超时或异常时清理并重连。这样既保持了协议的可扩展性,也让异常恢复有统一的入口。

设计协议时优先使用“定长头 + 长度字段”,它比分隔符方案更安全,因为负载中出现的任意字节都不会被误判为边界。

掌握以上内容后,客户端就不再是“能连上就行”的演示代码,而是具备明确消息边界、可配对请求响应、可处理超时与断线的工程实现。后续在服务端章节中,会看到同一套协议在服务端的解析与回写方式。