第11章 TCP回射服务器:你发什么,我回什么

说实话,回射服务器(Echo Server)是TCP网络编程里最经典的入门案例。它做的事情很简单:客户端发来什么数据,服务器原封不动地返回去。听起来像是个玩具?我在项目里还真用过这个模式——调试网络延迟、测试带宽、验证防火墙规则,回射服务器都是个趁手的工具。

这一章,我们就手写一个完整的TCP回射服务器。我会把代码拆开讲,每一行都说明白为什么这么写。嗯,咱们开始吧。

11.1 回射服务器的核心逻辑

回射服务器的流程,说白了就是三步:

  1. 接收数据:从已连接的客户端socket读取数据
  2. 原样发送:把读到的数据写回给客户端
  3. 循环处理:重复1和2,直到客户端关闭连接

你想想看,这个模型其实涵盖了TCP服务器最核心的读写操作。学会了它,后面做文件传输、聊天室、HTTP服务器,都是在这个基础上加逻辑而已。

核心要点:回射服务器是“短连接”还是“长连接”?答案是长连接。服务器会持续为同一个客户端服务,直到客户端主动断开。我在项目中遇到过有人把它做成短连接——每次收发完就close——结果客户端频繁重连,性能惨不忍睹。

11.2 代码实现:单进程版本

我们先写一个最简单的单进程版本。它一次只能服务一个客户端,但逻辑最清晰,适合理解原理。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#define PORT 8888
#define BUFFER_SIZE 1024

int main() {
    int server_fd, client_fd;
    struct sockaddr_in server_addr, client_addr;
    socklen_t client_len = sizeof(client_addr);
    char buffer[BUFFER_SIZE];
    ssize_t n;

    // 1. 创建socket
    server_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (server_fd < 0) {
        perror("socket");
        exit(1);
    }

    // 2. 设置地址复用(避坑!)
    int opt = 1;
    setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    // 3. 绑定地址
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    server_addr.sin_port = htons(PORT);

    if (bind(server_fd, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
        perror("bind");
        close(server_fd);
        exit(1);
    }

    // 4. 监听
    if (listen(server_fd, 5) < 0) {
        perror("listen");
        close(server_fd);
        exit(1);
    }

    printf("Echo server listening on port %d...\n", PORT);

    // 5. 接受连接
    client_fd = accept(server_fd, (struct sockaddr*)&client_addr, &client_len);
    if (client_fd < 0) {
        perror("accept");
        close(server_fd);
        exit(1);
    }

    printf("Client connected: %s:%d\n",
           inet_ntoa(client_addr.sin_addr),
           ntohs(client_addr.sin_port));

    // 6. 回射循环
    while ((n = read(client_fd, buffer, sizeof(buffer))) > 0) {
        // 原样写回
        write(client_fd, buffer, n);
    }

    // 7. 清理
    close(client_fd);
    close(server_fd);
    printf("Server closed.\n");
    return 0;
}

11.3 代码逐段解析

这段代码不长,但每个环节都有讲究。我按顺序讲几个关键点。

11.3.1 socket() 和 setsockopt()

socket(AF_INET, SOCK_STREAM, 0) 创建了一个TCP socket。AF_INET表示IPv4,SOCK_STREAM表示流式套接字(TCP)。第三个参数0表示使用默认协议。

注意后面的 setsockopt(..., SO_REUSEADDR, ...)。这个设置很重要。我曾经在调试时忘了加它,服务器崩溃重启后,bind总是报“Address already in use”。为什么?因为TCP的TIME_WAIT状态占着端口呢。加上SO_REUSEADDR,就能立即重用地址。

个人习惯:我写TCP服务器,第一件事就是加SO_REUSEADDR。不管是不是调试阶段,养成习惯总没错。

11.3.2 bind() 和 listen()

bind() 把socket和本地地址绑定。这里用 INADDR_ANY 表示监听所有网络接口。如果你只想监听某个特定IP,可以换成具体的地址。

listen() 的第二个参数是backlog,表示连接请求队列的最大长度。5是经典值,但生产环境建议设大一些,比如128或1024。

11.3.3 accept() 和回射循环

accept() 会阻塞,直到有客户端连接进来。它返回一个新的socket描述符 client_fd,后续的读写都用这个。

回射循环是核心:read() 读取客户端发来的数据,write() 原样写回。注意 read() 返回0表示客户端关闭连接,返回-1表示出错。循环条件 n > 0 正好处理这两种情况。

注意:这里的 read()write() 是阻塞调用。如果客户端不发数据,服务器会一直卡在 read() 上。单进程版本只能服务一个客户端,就是这个原因。后面我们会用多线程或epoll来解决。

11.4 客户端代码(测试用)

为了测试服务器,我们写一个简单的客户端。它从标准输入读取一行,发送给服务器,然后打印服务器返回的内容。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>

#define SERVER_IP "127.0.0.1"
#define PORT 8888
#define BUFFER_SIZE 1024

int main() {
    int sock;
    struct sockaddr_in server_addr;
    char buffer[BUFFER_SIZE];
    ssize_t n;

    sock = socket(AF_INET, SOCK_STREAM, 0);
    if (sock < 0) {
        perror("socket");
        exit(1);
    }

    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(PORT);
    inet_pton(AF_INET, SERVER_IP, &server_addr.sin_addr);

    if (connect(sock, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
        perror("connect");
        close(sock);
        exit(1);
    }

    printf("Connected to server. Type your message:\n");

    while (fgets(buffer, sizeof(buffer), stdin) != NULL) {
        write(sock, buffer, strlen(buffer));
        n = read(sock, buffer, sizeof(buffer) - 1);
        if (n <= 0) break;
        buffer[n] = '\0';
        printf("Echo: %s", buffer);
    }

    close(sock);
    return 0;
}

11.5 运行测试

先在一个终端启动服务器:

$ gcc echo_server.c -o echo_server
$ ./echo_server
Echo server listening on port 8888...

再开另一个终端运行客户端:

$ gcc echo_client.c -o echo_client
$ ./echo_client
Connected to server. Type your message:
hello
Echo: hello
world
Echo: world
(按Ctrl+D结束输入)

看到没?客户端发什么,服务器就回什么。这就是回射服务器。

11.6 核心流程图

下面这张图展示了TCP回射服务器的完整工作流程。从socket创建到连接关闭,每一步都标出来了。

TCP回射服务器工作流程 服务器端 1. socket() 创建TCP socket 2. bind() 绑定地址和端口 3. listen() 开始监听 4. accept() 等待客户端连接 5. read() 读取客户端数据 6. write() 原样写回数据 客户端 1. socket() 创建socket 2. connect() 连接服务器 3. write() 发送数据 4. read() 接收回射数据 TCP三次握手 数据 回射数据 循环直到read返回0

11.7 常见问题与避坑

问题 原因 解决方法
bind失败:Address already in use 端口被上一个进程占用,处于TIME_WAIT状态 添加SO_REUSEADDR选项
客户端连接不上 服务器没启动,或者防火墙拦截 检查服务器进程,用telnet测试端口
回射数据不完整 TCP是流式协议,一次read可能没读完 使用应用层协议头,或者循环读取直到收完
服务器只能服务一个客户端 单进程阻塞在read()上 使用多线程、多进程或I/O多路复用

我曾经踩过的坑:有一次线上服务器莫名其妙丢数据。查了半天,发现是read()返回的数据长度小于预期,而write()只写了实际读取的长度。客户端那边却以为收到了完整消息。从那以后,我养成了一个习惯——应用层协议一定要带长度字段,或者用固定分隔符。TCP是流,不是消息边界。

11.8 小结

回射服务器虽然简单,但它把TCP网络编程的骨架完整地展示了一遍:socket创建、bind、listen、accept、read、write、close。每一个环节都是后面复杂应用的基础。

我个人建议你亲手敲一遍代码,然后试着改一改——比如改成并发版本,或者加上日志输出。只有动手写过,才能真正理解这些API的行为。

嗯,这一章就到这里。代码在本地跑起来了吗?有问题随时翻回来看。


公众号:蓝海资料掘金营,微信deep3321