前言:
我们已经完成了
环境搭建和 MySQL 数据库初始化
客户端基本框架(连接服务器、菜单打印、注册 / 登录)
数据库操作类的完整实现
二、TCP Socket 通信完整流程详解(必懂)
| 服务器(接电话的人) | 动作 | 客户端(打电话的人) |
|---|---|---|
| 买手机 | socket() 创建套接字 |
买手机 |
| 绑定手机号 | bind() 绑定地址和端口 |
无 |
| 开机等电话 | listen() 开始监听 |
无 |
| 接电话 | accept() 接受连接 |
打电话 connect() 发起连接 |
| 通话 | recv()/send() 收发数据 |
通话 |
| 挂电话 | close() 关闭套接字 |
挂电话 |
重点理解:
服务器必须先启动,等待客户端连接
accept()会创建一个新的套接字 (就是int c),专门和这个客户端通信
原来的监听套接字继续等待其他客户端连接
三、服务器端
3.1 服务器实现文件(server/server.cpp)
先写 Server 类的核心实现:
cpp
#include "server.h"
Server::Server() {
cout << "服务器启动中..." << endl;
ip = "127.0.0.1";
port = 6000;
sockfd = -1;
base = NULL;
}
Server::Server(string ip, int port) {
this->ip = ip;
this->port = port;
sockfd = -1;
base = NULL;
}
Server::~Server() {
cout << "服务器关闭" << endl;
if (sockfd != -1) {
close(sockfd);
}
if (base != NULL) {
event_base_free(base); // 释放Libevent事件基础
}
}
//创建监听套接字
bool Server::Create_Socket() {
sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (-1 == sockfd) {
perror("socket create failed");
return false;
}
// 重点:设置端口复用
// 解决服务器重启后"地址已被使用"的问题
int opt = 1;
setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
// 2. 填充服务器地址结构
struct sockaddr_in saddr;
memset(&saddr, 0, sizeof(saddr)); // 清空结构,防止垃圾数据
saddr.sin_family = AF_INET; // 地址族:IPv4
saddr.sin_port = htons(port); // 端口号:主机字节序转网络字节序
// INADDR_ANY:监听所有网卡的地址
saddr.sin_addr.s_addr = htonl(INADDR_ANY);
// 3. 绑定地址和端口到套接字
int res = bind(sockfd, (struct sockaddr*)&saddr, sizeof(saddr));
if (-1 == res) {
perror("bind failed");
close(sockfd);
return false;
}
// 4. 开始监听客户端连接
// LIS_MAX:监听队列的最大长度,即同时等待连接的客户端数量
res = listen(sockfd, LIS_MAX);
if (-1 == res) {
perror("listen failed");
close(sockfd);
return false;
}
cout << "监听套接字创建成功,端口:" << port << endl;
return true;
}
// 初始化Libevent事件驱动框架
bool Server::Libevent_Init() {
// 1. 创建事件基础对象(Libevent的核心,管理所有事件)
// 相当于Libevent的"大脑"
base = event_base_new();
if (nullptr == base) {
cout << "event_base_new failed" << endl;
return false;
}
// 2. 创建监听套接字的读事件
// 参数说明:
// base:事件基础对象
// sockfd:要监听的文件描述符(监听套接字)
// EV_READ:监听读事件(有客户端连接时触发)
// EV_PERSIST:持久事件,触发后不会自动删除
// Accept_CallBack:事件触发时的回调函数
// this:传递给回调函数的参数(当前Server对象的指针)
struct event* sock_ev = event_new(base, sockfd,
EV_READ | EV_PERSIST,
Accept_CallBack,
static_cast<void*>(this));
if (sock_ev == nullptr) {
cout << "event_new failed" << endl;
return false;
}
// 3. 将事件添加到事件基础中,开始监听
event_add(sock_ev, nullptr);
cout << "Libevent初始化成功" << endl;
return true;
}
// 服务器总初始化函数
bool Server::Ser_Init() {
// 1. 创建文件存储根目录(如果不存在)
if (access(PATH.c_str(), F_OK) == -1) {
if (mkdir(PATH.c_str(), 0775) == -1) {
cout << "创建根目录失败:" << PATH << endl;
return false;
}
cout << "创建文件根目录:" << PATH << endl;
}
// 2. 创建监听套接字
if (!Create_Socket()) {
return false;
}
// 3. 初始化Libevent
if (!Libevent_Init()) {
return false;
}
return true;
}
// 接受客户端连接
bool Server::Accept_Client() {
// 接受一个新的客户端连接
// 返回值:新的套接字(int c),专门和这个客户端通信
int c = accept(sockfd, NULL, NULL);
if (c == -1) {
perror("accept failed");
return false;
}
cout << "新客户端连接成功,套接字:" << c << endl;
// ⚠️ 重点:为每个新客户端创建一个Con_Client对象
// 这个对象会管理这个客户端的所有状态
Con_Client* p = new Con_Client(c, base);
// 为这个客户端创建读事件,监听它发来的数据
struct event* c_ev = event_new(base, c,
EV_READ | EV_PERSIST,
Read_CallBack,
static_cast<void*>(p));
if (c_ev == NULL) {
delete p;
close(c);
return false;
}
// 将事件对象保存到Con_Client中,方便后续释放
p->Set_event(c_ev);
// 将事件添加到事件基础中,开始监听这个客户端的数据
event_add(c_ev, NULL);
return true;
}
// 启动服务器事件循环(阻塞函数,一直运行)
void Server::Run() {
cout << "服务器启动成功,开始监听客户端连接..." << endl;
// 启动事件循环,等待事件发生
// 这个函数会一直阻塞,直到event_base被销毁
event_base_dispatch(base);
}
/************************** 全局回调函数 **************************/
// 监听套接字的读事件回调:有新客户端连接时触发
void Accept_CallBack(int fd, short event, void* arg) {
// 将void*参数转换回Server对象指针
Server* ser = static_cast<Server*>(arg);
// 调用Server类的Accept_Client方法接受连接
ser->Accept_Client();
}
// 客户端套接字的读事件回调:有客户端发来数据时触发
void Read_CallBack(int fd, short event, void* arg) {
// 将void*参数转换回Con_Client对象指针
Con_Client* cli = static_cast<Con_Client*>(arg);
// 调用Con_Client类的Recv_Data方法接收数据
cli->Recv_Data();
}
重点代码讲解:
setsockopt(SO_REUSEADDR):非常重要!解决服务器重启后 "地址已被使用" 的问题,因为 TCP 有 TIME_WAIT 状态,端口不会立即释放。event_base:Libevent 的核心,相当于一个事件管理器,所有事件都要注册到它上面。event_new():创建一个事件对象,指定要监听的文件描述符、事件类型和回调函数。- 回调函数:Libevent 是事件驱动的,当事件发生时,会自动调用你注册的回调函数。
- 每个客户端一个 Con_Client 对象:这是面向对象设计的精髓,把每个客户端的状态和行为封装在一起。
3.2 Con_Client 类核心实现
cpp
/************************** Con_Client类实现 **************************/
// 构造函数:初始化客户端连接对象
Con_Client::Con_Client(int c, struct event_base* b) {
this->c = c; // 保存客户端套接字
base = b; // 保存事件基础对象
ev = NULL; // 事件对象初始化为空
fd = -1; // 文件描述符初始化为无效值
mypath = ""; // 当前工作目录初始化为空
userpath = ""; // 用户根目录初始化为空
}
// 析构函数:释放客户端资源
Con_Client::~Con_Client() {
if (ev != NULL) {
event_free(ev); // 释放事件对象
}
close(c); // 关闭客户端套接字
if (fd != -1) {
close(fd); // 关闭打开的文件
}
cout << "客户端断开连接,套接字:" << c << endl;
}
// 设置事件对象
void Con_Client::Set_event(struct event* e) {
ev = e;
}
// 判断数据是否是JSON格式
bool Con_Client::is_json(const char buff[]) {
// 简单判断:JSON以'{'开头
return buff[0] == '{';
}
// 发送成功响应
void Con_Client::send_ok() {
Json::Value v;
v["status"] = "OK";
send(c, v.toStyledString().c_str(), strlen(v.toStyledString().c_str()), 0);
}
// 发送失败响应
void Con_Client::send_err() {
Json::Value v;
v["status"] = "ERR";
send(c, v.toStyledString().c_str(), strlen(v.toStyledString().c_str()), 0);
}
// 发送JSON响应
void Con_Client::send_Json(Json::Value& v) {
send(c, v.toStyledString().c_str(), strlen(v.toStyledString().c_str()), 0);
}
// 处理注册请求
void Con_Client::Register() {
// 从JSON中提取用户信息
string usertel = val["usertel"].asString();
string username = val["username"].asString();
string userpasswd = val["passwd"].asString();
// 创建数据库客户端
mysqlclient mysqlcli;
if (!mysqlcli.connectserver()) {
send_err();
return;
}
// 将用户信息插入数据库
if (!mysqlcli.db_register(usertel, username, userpasswd)) {
send_err();
return;
}
// 为新用户创建专属目录
mypath = PATH + usertel;
userpath = mypath;
if (mkdir(mypath.c_str(), 0775) == -1) {
send_err();
return;
}
cout << "用户注册成功:" << username << "(" << usertel << ")" << endl;
send_ok();
}
// 处理登录请求
void Con_Client::Login() {
// 从JSON中提取用户信息
string usertel = val["usertel"].asString();
string passwd = val["passwd"].asString();
string username = "";
// 输入验证
if (usertel.empty() || passwd.empty()) {
send_err();
return;
}
// 创建数据库客户端
mysqlclient cli;
if (!cli.connectserver()) {
send_err();
return;
}
// 验证用户登录
if (!cli.db_login(usertel, username, passwd)) {
send_err();
return;
}
// 初始化用户目录
mypath = PATH + usertel;
userpath = mypath;
// 如果用户目录不存在(比如数据库是手动导入的),创建它
if (access(mypath.c_str(), F_OK) == -1) {
if (mkdir(mypath.c_str(), 0775) == -1) {
send_err();
return;
}
}
cout << "用户登录成功:" << username << "(" << usertel << ")" << endl;
// 返回登录成功响应和用户名
Json::Value v;
v["status"] = "OK";
v["username"] = username;
send_Json(v);
}
// 处理查看文件列表请求
void Con_Client::showfiles() {
// 打开用户当前目录
DIR* ptr = opendir(mypath.c_str());
if (ptr == NULL) {
cout << "打开目录失败:" << mypath << endl;
send_err();
return;
}
int ndirs = 0; // 目录数量
int nfiles = 0; // 文件数量
Json::Value resval;
struct dirent *s = nullptr;
struct stat st;
// 遍历目录中的所有文件和子目录
while ((s = readdir(ptr)) != nullptr) {
// 跳过.和..目录
if (strncmp(s->d_name, ".", 1) == 0) {
continue;
}
// 构建文件的完整路径
string filename = mypath + "/" + s->d_name;
// 获取文件属性
if (lstat(filename.c_str(), &st) == -1) {
cout << "获取文件属性失败:" << filename << endl;
continue;
}
// 判断是目录还是普通文件
if (S_ISDIR(st.st_mode)) {
// 是目录,添加到arrdir数组
Json::Value tmp;
tmp["filename"] = string(s->d_name);
resval["arrdir"].append(tmp);
ndirs++;
} else {
// 是普通文件,添加到arrfile数组
Json::Value tmp;
tmp["filename"] = string(s->d_name);
resval["arrfile"].append(tmp);
nfiles++;
}
}
// 关闭目录
closedir(ptr);
// 构建响应JSON
resval["ndirs"] = ndirs;
resval["status"] = "OK";
resval["nfiles"] = nfiles;
// 发送响应给客户端
send(c, resval.toStyledString().c_str(), strlen(resval.toStyledString().c_str()), 0);
}
// 处理下载文件请求
void Con_Client::get_file(char* ptr) {
// 解析命令:get start filename 或 get continue 或 get stop
char *status = strtok_r(NULL, " ", &ptr);
if (status == nullptr) {
send(c, "ERR", 3, 0);
return;
}
if (strcmp(status, "start") == 0) {
// 开始下载:获取文件名
char* fname = strtok_r(NULL, " ", &ptr);
if (fname == nullptr) {
send(c, "ERR", 3, 0);
return;
}
// 构建文件完整路径
string pathname = mypath + "/" + fname;
// 以只读方式打开文件
fd = open(pathname.c_str(), O_RDONLY);
if (fd == -1) {
send(c, "ERR", 3, 0);
return;
}
// 获取文件大小:移动文件指针到末尾,返回偏移量
int filesize = lseek(fd, 0, SEEK_END);
// 移动文件指针回到开头
lseek(fd, 0, SEEK_SET);
// 发送响应:OK + 文件大小
string r_str = "OK " + to_string(filesize);
send(c, r_str.c_str(), strlen(r_str.c_str()), 0);
cout << "开始发送文件:" << fname << ",大小:" << filesize << "字节" << endl;
}
else if (strcmp(status, "continue") == 0) {
// 继续下载:发送下一块数据
if (fd == -1) {
send(c, "ERR", 3, 0);
return;
}
char buff[128] = {0};
// 读取128字节数据
int num = read(fd, buff, 128);
if (num <= 0) {
// 文件读完或出错,关闭文件
close(fd);
fd = -1;
send(c, "", 0, 0);
cout << "文件发送完成" << endl;
return;
}
// 发送数据给客户端
send(c, buff, num, 0);
}
else if (strcmp(status, "stop") == 0) {
// 停止下载:关闭文件
if (fd != -1) {
close(fd);
fd = -1;
}
cout << "下载被客户端终止" << endl;
}
}
// 处理新建目录请求
void Con_Client::Mkdir() {
string dname = val["dirname"].asString();
string filepath = mypath + "/" + dname;
// 创建目录,权限0775
if (mkdir(filepath.c_str(), 0775) == -1) {
send_err();
return;
}
cout << "创建目录成功:" << dname << endl;
send_ok();
}
// 处理删除文件/目录请求
void Con_Client::Rmfile() {
string filename = val["filename"].asString();
string filepath = mypath + "/" + filename;
// 检查文件是否存在
if (access(filepath.c_str(), F_OK) == -1) {
send_err();
return;
}
// 获取文件属性
struct stat st;
if (stat(filepath.c_str(), &st) == -1) {
send_err();
return;
}
// 判断是目录还是普通文件
if (S_ISDIR(st.st_mode)) {
// 删除空目录
if (rmdir(filepath.c_str()) == -1) {
send_err();
return;
}
} else {
// 删除普通文件
if (unlink(filepath.c_str()) == -1) {
send_err();
return;
}
}
cout << "删除成功:" << filename << endl;
send_ok();
}
// 处理重命名请求
void Con_Client::Rename() {
string s_name = val["sname"].asString();
string t_name = val["tname"].asString();
string s_filepath = mypath + "/" + s_name;
string t_filepath = mypath + "/" + t_name;
// 重命名文件/目录
if (rename(s_filepath.c_str(), t_filepath.c_str()) == -1) {
send_err();
return;
}
cout << "重命名成功:" << s_name << " -> " << t_name << endl;
send_ok();
}
// 处理进入目录请求
void Con_Client::Chdir() {
string dname = val["dirname"].asString();
string testpath = mypath + "/" + dname;
// 尝试打开目录,检查是否存在
DIR* ptr = opendir(testpath.c_str());
if (ptr == nullptr) {
send_err();
return;
}
// 更新当前工作目录
mypath = testpath;
closedir(ptr);
cout << "切换目录成功:" << mypath << endl;
send_ok();
}
// 处理返回上级目录请求
void Con_Client::Ret() {
// 如果已经在用户根目录,不能再返回
if (userpath == mypath) {
send_ok();
return;
}
// 找到最后一个'/'的位置
size_t pos = mypath.find_last_of("/");
if (pos == string::npos) {
send_err();
return;
}
// 截取到最后一个'/'之前的部分,就是上级目录
mypath = mypath.substr(0, pos);
cout << "返回上级目录成功:" << mypath << endl;
send_ok();
}
// 请求分发函数:根据操作类型调用对应的处理函数
void Con_Client::do_run(int op) {
switch (op) {
case REGISTER:
Register();
break;
case LOGIN:
Login();
break;
case SHOWFILES:
showfiles();
break;
case GET:
// GET请求已经在Recv_Data中单独处理了
break;
case POST:
send_err();
break;
case MKDIR:
Mkdir();
break;
case RMFILE:
Rmfile();
break;
case MVNAME:
Rename();
break;
case CHDIR:
Chdir();
break;
case RET:
Ret();
break;
case MVFILE:
send_err();
break;
case USEREXIT:
break;
default:
send_err();
break;
}
}
// 接收并处理客户端数据
void Con_Client::Recv_Data() {
char buff[256] = {0};
// 接收客户端数据
int n = recv(c, buff, 255, 0);
if (n <= 0) {
// 客户端断开连接或出错,删除Con_Client对象
delete this;
return;
}
cout << "收到客户端" << c << "数据:" << buff << endl;
// 判断数据类型:JSON或自定义协议
if (is_json(buff)) {
// 解析JSON数据
Json::Reader Read;
if (!Read.parse(buff, val)) {
cout << "JSON解析失败" << endl;
send_err();
return;
}
// 获取操作类型
int op = val["type"].asInt();
// 分发请求
do_run(op);
} else {
// 自定义协议:目前只有下载协议
char* ptr = nullptr;
char* s = strtok_r(buff, " ", &ptr);
if (s == nullptr) {
send(c, "ERR", 3, 0);
return;
}
if (strcmp(s, "get") == 0) {
// 处理下载请求
get_file(ptr);
} else if (strcmp(s, "up") == 0) {
cout << "上传功能暂未实现" << endl;
send(c, "ERR", 3, 0);
} else {
send(c, "ERR", 3, 0);
}
}
}
// 主函数
int main() {
Server ser;
if (!ser.Ser_Init()) {
cout << "服务器初始化失败" << endl;
return 1;
}
ser.Run();
return 0;
}
重点代码讲解:
opendir()/readdir()/closedir():Linux 下目录操作的三个核心函数,用于遍历目录中的文件。lstat():获取文件属性,通过S_ISDIR(st.st_mode)判断是否是目录。strtok_r():线程安全的字符串分割函数,用于解析自定义的下载协议。lseek(fd, 0, SEEK_END):获取文件大小的常用技巧,将文件指针移动到末尾,返回的偏移量就是文件大小。- 分块下载:服务器每次发送 128 字节数据,客户端每次请求一块,这样可以避免大文件一次性发送导致的内存问题。