5、TCP回射服务器:实现一个简单的echo服务器,客户端发送什么就返回什么。
各位同学,今天我们来聊一个网络编程里最经典的入门案例——TCP回射服务器。说白了,就是客户端发什么,服务器就原封不动地返回什么。听起来很简单对吧?但别小看它,这个模型是所有复杂网络服务的基础骨架。
我个人习惯把这种服务器叫做“照镜子服务器”。你对着它喊一声“hello”,它就回你一声“hello”。嗯,这里要注意,虽然功能简单,但背后涉及的socket创建、绑定、监听、接受连接、读写数据这些流程,一个都少不了。
5.1 核心流程:服务器到底在干什么?
我们先理清思路。一个TCP回射服务器的工作流程,其实就五步:
- 创建socket——拿一个通信的“门牌号”
- 绑定地址——把门牌号挂到具体的IP和端口上
- 监听——告诉系统,我准备好接客了
- 接受连接——有客户端敲门,就开门
- 读写数据——读客户端发来的数据,再原样写回去
我刚开始学的时候,总觉得第4步和第5步应该分开写,结果代码跑起来总是各种段错误。后来才明白,accept()返回的是一个新的socket描述符,专门用来和这个客户端通信。原来的监听socket还在继续等别人敲门呢。
下面这张图,把整个流程串起来了:
5.2 代码实现:手写一个echo服务器
好了,理论说完了,咱们直接上代码。我习惯把服务器代码分成几个清晰的模块,这样调试起来也方便。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.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];
int n;
// 1. 创建socket
server_fd = socket(AF_INET, SOCK_STREAM, 0);
if (server_fd < 0) {
perror("socket创建失败");
exit(1);
}
// 2. 绑定地址
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);
}
// 3. 监听
if (listen(server_fd, 5) < 0) {
perror("listen监听失败");
close(server_fd);
exit(1);
}
printf("服务器已启动,监听端口 %d...\n", PORT);
// 4. 接受连接并处理
while (1) {
client_fd = accept(server_fd, (struct sockaddr*)&client_addr, &client_len);
if (client_fd < 0) {
perror("accept接受连接失败");
continue;
}
printf("客户端已连接: %s:%d\n",
inet_ntoa(client_addr.sin_addr),
ntohs(client_addr.sin_port));
// 5. 回射数据
while ((n = read(client_fd, buffer, BUFFER_SIZE - 1)) > 0) {
buffer[n] = '\0';
printf("收到: %s", buffer);
write(client_fd, buffer, n);
}
printf("客户端断开连接\n");
close(client_fd);
}
close(server_fd);
return 0;
}
关键点解析:
INADDR_ANY表示绑定到本机所有IP地址,这样不管是内网还是外网都能访问htonl()和htons()是字节序转换函数,网络字节序是大端模式read()返回0表示对方关闭了连接,返回-1表示出错
5.3 客户端代码:怎么跟服务器玩?
有服务器就得有客户端。不然你拿什么测试?我自己写客户端的时候,喜欢加上命令行参数,这样测试起来更灵活。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define PORT 8888
#define BUFFER_SIZE 1024
int main(int argc, char *argv[]) {
int sock_fd;
struct sockaddr_in server_addr;
char buffer[BUFFER_SIZE];
int n;
if (argc != 2) {
fprintf(stderr, "用法: %s <服务器IP>\n", argv[0]);
exit(1);
}
// 1. 创建socket
sock_fd = socket(AF_INET, SOCK_STREAM, 0);
if (sock_fd < 0) {
perror("socket创建失败");
exit(1);
}
// 2. 连接服务器
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(PORT);
inet_pton(AF_INET, argv[1], &server_addr.sin_addr);
if (connect(sock_fd, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
perror("connect连接失败");
close(sock_fd);
exit(1);
}
printf("已连接到服务器 %s:%d\n", argv[1], PORT);
// 3. 发送数据并接收回射
while (1) {
printf("请输入消息: ");
if (fgets(buffer, BUFFER_SIZE, stdin) == NULL) {
break;
}
write(sock_fd, buffer, strlen(buffer));
n = read(sock_fd, buffer, BUFFER_SIZE - 1);
if (n <= 0) {
printf("服务器断开连接\n");
break;
}
buffer[n] = '\0';
printf("服务器回射: %s", buffer);
}
close(sock_fd);
return 0;
}
5.4 避坑指南:我曾经踩过的坑
写这个echo服务器的时候,我遇到过几个特别坑的问题,分享给大家:
坑1:端口被占用
我曾经在调试的时候,程序崩溃了没来得及释放端口。第二次启动就报“Address already in use”。解决办法是在bind之前设置socket选项:
int opt = 1;
setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
坑2:read() 不保证一次读完所有数据
TCP是流式协议,没有消息边界。你发送一个“hello”,对方可能分两次收到“he”和“llo”。所以实际项目中,需要自己设计应用层协议来界定消息边界。不过我们这个echo服务器比较简单,一次读写就够了。
小技巧: 测试的时候可以用 telnet 命令快速验证服务器是否正常工作:
telnet 127.0.0.1 8888
输入任意字符,看服务器是否原样返回。如果返回了,说明你的echo服务器跑起来了。
5.5 运行效果演示
假设服务器IP是192.168.1.100,运行效果如下:
| 终端 | 输出内容 |
|---|---|
| 服务器端 |
服务器已启动,监听端口 8888... 客户端已连接: 192.168.1.101:54321 收到: hello 收到: world 客户端断开连接 |
| 客户端 |
已连接到服务器 192.168.1.100:8888 请输入消息: hello 服务器回射: hello 请输入消息: world 服务器回射: world 请输入消息: ^C |
你看,客户端发什么,服务器就回什么。这就是最朴素的echo模型。你想想看,很多高级的协议,比如HTTP、FTP,底层都是基于这种请求-响应模式的。只不过它们加上了更复杂的头部信息和状态管理。
我个人建议,初学者一定要亲手把这段代码敲一遍,然后编译运行。遇到问题不要怕,用 perror() 打印错误信息,用 gdb 调试,慢慢就熟悉了。