Build Your Own Redis with C/C++ 学习(三)

03. TCP 服务器与客户端开发指南

本章目标:熟悉 Socket API,编写一个基础的 TCP 回显服务器(Echo Server)和客户端。

⚠️ 注意:这里的代码虽然能跑,但仅仅是为了演示 API 的用法。真正的网络编程远不止调用 API 这么简单,完整的错误处理和架构设计将在后续章节展开。

3.1 先决条件 (Prerequisites)

1. 熟悉 Linux 环境

网络编程的原理通用,但 Windows/macOS 的系统调用细节差异很大。对于初学者,强烈推荐使用 Linux

  • 环境获取:VirtualBox 虚拟机、WSL (Windows Subsystem for Linux) 或云服务器 (VPS)。
  • 必备技能
    • 文件操作:编辑、复制、移动、删除。
    • 编译代码:使用 g++。不需要复杂的 Makefile。
      1
      2
      g++ -Wall -Wextra -Og -g source.cpp -o program
      ./program

2. 基本编程技能 (C/C++)

虽然本项目主要使用 C 语言风格,但会用到少量的 C++ 特性(如 vector, string)以简化开发。

  • 核心概念:数组、结构体、内存管理、指针。
  • 调试技能
    • printf():最朴素但有效的调试手段。
    • assert():验证假设条件。
    • strace神器,用于跟踪程序执行的系统调用。
    • gdb:调试崩溃(Core Dump)和查看堆栈。
  • 动态数组:理解形如下面的结构:
    1
    struct MyString { char *data; size_t length; size_t capacity; };

3. 学会查阅文档 (Man Pages)

Linux 的手册页 (Man Pages) 是最权威的文档。

  • man socket.2:查看 socket() 系统调用(Section 2 代表系统调用)。
  • man socket.7:查看套接字接口的综述(Section 7 代表杂项/协议)。
  • 提示:Man Pages 适合查阅细节,不适合入门学习。入门推荐阅读《Beej’s Guide to Network Programming》。

3.2 实战:编写 TCP 服务器 (Server)

我们将把上一章的伪代码转化为可运行的 C++ 代码。

步骤 1: 获取套接字句柄 (Socket Handle)

1
int fd = socket(AF_INET, SOCK_STREAM, 0);
  • AF_INET: IPv4。
  • SOCK_STREAM: TCP 流。
  • 0: 默认协议。

步骤 2: 设置套接字选项 (Set Options)

1
2
int val = 1;
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &val, sizeof(val));
  • **关键选项 SO_REUSEADDR**:必须设置为 1。
  • 作用:允许服务器重启后立即绑定到同一个端口。如果不设置,重启时可能会报错 “Address already in use”,因为旧连接处于 TIME_WAIT 状态。

步骤 3: 绑定地址 (Bind)

我们将服务器绑定到 0.0.0.0:1234

1
2
3
4
5
6
7
struct sockaddr_in addr = {};
addr.sin_family = AF_INET;
addr.sin_port = htons(1234); // 端口号 (主机字节序 -> 网络字节序)
addr.sin_addr.s_addr = htonl(0); // IP 0.0.0.0 (通配符地址)

int rv = bind(fd, (const struct sockaddr *)&addr, sizeof(addr));
if (rv) { die("bind()"); }
  • **字节序 (Endianness)**:
    • **Little-endian (小端)**:低位字节在前 (Intel/AMD CPU)。
    • **Big-endian (大端/网络字节序)**:高位字节在前。
    • 转换函数htons() (Host to Network Short), htonl() (Host to Network Long)。网络传输必须使用大端序。

步骤 4: 监听 (Listen)

1
2
rv = listen(fd, SOMAXCONN);
if (rv) { die("listen()"); }
  • SOMAXCONN: 监听队列的最大长度(Linux 上通常是 4096)。

步骤 5: 接受连接 (Accept)

1
2
3
4
5
6
7
8
9
10
while (true) {
struct sockaddr_in client_addr = {};
socklen_t addrlen = sizeof(client_addr);
// accept 会阻塞,直到有新连接
int connfd = accept(fd, (struct sockaddr *)&client_addr, &addrlen);
if (connfd < 0) { continue; }

do_something(connfd); // 处理连接
close(connfd); // 关闭连接
}

步骤 6: 读写数据 (Read & Write)

1
2
3
4
5
6
7
8
9
10
11
12
static void do_something(int connfd) {
char rbuf[64] = {};
ssize_t n = read(connfd, rbuf, sizeof(rbuf) - 1);
if (n < 0) {
msg("read() error");
return;
}
printf("client says: %s\n", rbuf);

char wbuf[] = "world";
write(connfd, wbuf, strlen(wbuf));
}

3.3 实战:编写 TCP 客户端 (Client)

客户端逻辑:连接 -> 发送 “hello” -> 读取响应 -> 退出。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) { die("socket()"); }

struct sockaddr_in addr = {};
addr.sin_family = AF_INET;
addr.sin_port = ntohs(1234);
addr.sin_addr.s_addr = ntohl(INADDR_LOOPBACK); // 127.0.0.1

// 发起连接
int rv = connect(fd, (const struct sockaddr *)&addr, sizeof(addr));
if (rv) { die("connect"); }

// 发送数据
char msg[] = "hello";
write(fd, msg, strlen(msg));

// 接收数据
char rbuf[64] = {};
ssize_t n = read(fd, rbuf, sizeof(rbuf) - 1);
if (n < 0) { die("read"); }

printf("server says: %s\n", rbuf);
close(fd);

编译与运行:

1
2
3
4
5
6
7
8
9
10
11
12
13
# 编译
g++ -Wall -Wextra -O2 -g 03_server.cpp -o server
g++ -Wall -Wextra -O2 -g 03_client.cpp -o client

# 运行 (建议开两个终端窗口)
# 窗口 1
./server

# 窗口 2
./client
# 输出: server says: world

# 窗口 1 输出: client says: hello

3.4 深入 Socket API 细节

1. 奇怪的 struct sockaddr

Socket API 设计于几十年前,那时还没有 void* 这种通用指针的概念,也没有现代的泛型。

  • struct sockaddr: 一个通用的占位符,实际上毫无用处。
  • struct sockaddr_in: IPv4 专用结构体(我们主要用这个)。
  • struct sockaddr_in6: IPv6 专用结构体。
  • 用法:我们需要将 sockaddr_in 强制类型转换为 sockaddr* 传给 API。

2. 系统调用 vs 库函数

  • 在 Linux 上,socket 函数直接对应内核的 Syscalls
  • getaddrinfo() 是个特例:它不是系统调用,而是 libc 中的库函数。因为它涉及复杂的域名解析流程(读配置文件、DNS 查询等)。

3. 获取地址信息

如果你使用了通配符 IP 或动态端口,你可能不知道当前的连接地址。

  • getsockname(): 获取本地绑定的地址。
  • getpeername(): 获取远程连接的地址。

4. Socket 与 IPC (进程间通信)

  • Unix Domain Sockets: 用于同一台机器上进程间的高效通信。API 与网络 Socket 几乎一致,只是地址族不同 (AF_UNIX),且不需要经过网卡协议栈。

5. 读写函数的变体

除了标准的 read/write,还有很多变体:

  • recv/send: 多了一个 flags 参数。
  • readv/writev: 分散/聚集 I/O。可以一次性读写多个不连续的缓冲区(非常有用,后续可能会用到)。
  • recvmsg/sendmsg: 最强大的变体,能控制所有细节。

下一步:
现在的服务器一次只能处理一个客户端,而且非常脆弱。下一章,我们将深入协议设计,学习如何处理 TCP 字节流的粘包与拆包问题。