5. Socket 编码问题

概念

Socket 没有“一次发送一条字符串”的保证。发送方如果按平台默认编码写出中文,接收方用另一种字符集解码,就会乱码。课程例程统一使用 UTF-8,并在代码里写明,不依赖操作系统默认值。 另一个问题是粘包和拆包。TCP 是字节流,两次 send 可能被一次 recv 一起读到,一次 send 也可能要多次 recv 才读完。字符串不能靠“读到的这一截”当一条消息。做法是先发送 4 字节长度,再发送这么多 UTF-8 字节;接收方循环读取,直到凑满长度。

  • 字符编码:Java 使用 StandardCharsets.UTF_8,Python 使用 encode/decode('utf-8')。
  • 不要把 InputStream.available 或一次 recv 的返回值当成消息结束。
  • 长度前缀使用网络字节序,也就是大端 4 字节整数。
  • 长度与正文必须一起按字节处理。先把正文按 UTF-8 变成字节,再记录字节长度,而不是字符个数。

算法流程

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

长度前缀与 UTF-8(UML 时序图)

sequenceDiagram participant Sender as 发送方 participant Stream as TCP 字节流 participant Receiver as 接收方 Sender->>Sender: 文本按 UTF-8 变成 body,记下字节数 Sender->>Stream: 4 字节大端长度,再写 body loop 直到凑满 4 字节 Receiver->>Stream: 读取 end Receiver->>Receiver: 解析出正文长度 n loop 直到凑满 n 字节 Receiver->>Stream: 继续读取 end Receiver->>Receiver: 按 UTF-8 解码成文本

Java 例程

import java.io.EOFException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;

public class SocketEncoding {
    /** 先写 4 字节长度,再写 UTF-8 正文。长度是字节数,不是字符数。 */
    static void send(OutputStream out, String text) throws Exception {
        byte[] body = text.getBytes(StandardCharsets.UTF_8); // 明确 UTF-8,不用平台默认编码
        // ByteBuffer 默认大端,和网络字节序一致
        byte[] head = ByteBuffer.allocate(4).putInt(body.length).array();
        out.write(head);
        out.write(body);
        out.flush();                              // 避免长度和正文滞留在缓冲区
    }

    static String recv(InputStream in) throws Exception {
        int n = ByteBuffer.wrap(readFully(in, 4)).getInt(); // 先恰好读满 4 字节
        if (n < 0) throw new EOFException();      // 长度非法就不要继续分配内存
        return new String(readFully(in, n), StandardCharsets.UTF_8);
    }

    /** 一次 read 可能只返回一部分,必须循环到凑满 n 个字节。 */
    static byte[] readFully(InputStream in, int n) throws Exception {
        byte[] buf = new byte[n];
        int off = 0;
        while (off < n) {
            int r = in.read(buf, off, n - off);   // 从 off 处接着填,不要覆盖已读到的字节
            if (r < 0) throw new EOFException();  // 对端提前关闭,这条消息不完整
            off += r;
        }
        return buf;
    }
}

Python 例程

def send_text(sock, text):
    body = text.encode("utf-8")          # “华南农业大学”是 6 个字符,UTF-8 下是 18 个字节
    # to_bytes(4, "big"):4 字节、大端,和 Java ByteBuffer 的默认字节序一致
    sock.sendall(len(body).to_bytes(4, "big") + body)

def recv_text(sock):
    n = int.from_bytes(recvn(sock, 4), "big")  # 先读长度,再按长度读正文
    return recvn(sock, n).decode("utf-8")      # 解码字符集必须和发送端相同

def recvn(sock, n):
    buf = b""
    while len(buf) < n:                   # 一次 recv 可能少于 n,不能把这次返回值当成一条消息
        chunk = sock.recv(n - len(buf))   # 只再要还缺的那些字节
        if not chunk:                     # 空结果表示对端关闭,消息被截断
            raise EOFError("连接中途关闭")
        buf += chunk
    return buf

# 错误对照:发送端用 gbk,接收端用 utf-8,中文会解码失败或乱码
# sock.sendall("华南农业大学".encode("gbk"))