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