服务器预约系统linux小项目-第五节课

一、先讲客户端:Client::Dl_user() 到底在做什么

客户端登录函数的作用,可以先用一句最简单的话记住:

客户端负责把"用户输入的手机号和密码"发给服务器,再把服务器返回的登录结果接回来。

它不负责判断密码对不对。

它只是负责:

  • 收集输入

  • 组装请求

  • 发请求

  • 收结果

  • 更新客户端状态

你可以把客户端想成"前台接待员"。

前台接到用户资料后,不会自己审核,而是把资料交给后台。后台审核完,再把结果告诉前台。

1)先看完整代码

cpp 复制代码
void Client::Dl_user()
{
    // 定义两个字符串变量
    // user_tel 用来保存用户输入的手机号
    // user_passwd 用来保存用户输入的密码
    string user_tel;
    string user_passwd;

    // 提示用户输入登录信息
    cout << "输入登录的手机号码:" << endl;
    cin >> user_tel;
    cout << "输入用户密码" << endl;
    cin >> user_passwd;

    // 清空旧的 JSON 数据
    // 因为 val 是类成员变量,不是局部变量
    // 如果不清空,可能残留上一次请求的数据
    val.clear();

    // 组装登录请求 JSON
    // type = "DL" 表示这是一个登录请求
    // user_tel 表示登录手机号
    // user_passwd 表示登录密码
    val["type"] = "DL";
    val["user_tel"] = user_tel;
    val["user_passwd"] = user_passwd;

    // 把 JSON 对象序列化成字符串
    // 然后通过 socket 发送给服务器
    string send_str = val.toStyledString();
    send(sockfd, send_str.c_str(), strlen(send_str.c_str()), 0);

    // 再次清空 val
    // 因为下面要用它来接收并解析服务器返回的 JSON
    val.clear();

    // 定义接收缓冲区 status_buff
    // 用来保存服务器返回的响应数据
    char status_buff[128] = {0};

    // 接收服务器返回的数据
    int num = recv(sockfd, status_buff, 127, 0);

    // 如果 num <= 0,说明服务器关闭连接或者接收失败
    if (num <= 0)
    {
        cout << "ser close" << endl;
        return;
    }

    // 把服务器返回的 JSON 字符串反序列化成 JSON 对象
    if (!read.parse(status_buff, val))
    {
        cout << "解析登录返回协议失败" << endl;
        return;
    }

    // 读取服务器返回的状态字段
    string status = val["status"].asString();

    // 如果状态不是 OK,说明登录失败
    if (status.compare("OK") != 0)
    {
        cout << "登陆失败" << endl;
        return;
    }

    // 如果能走到这里,说明登录成功
    // 取出服务器返回的用户名
    curr_username = val["user_name"].asString();

    // 修改客户端登录状态
    // true 表示当前已经登录
    dl_flg = true;

    cout << "登陆成功" << endl;
}

2)这段代码一行一行在干什么

第一部分:接收用户输入

cpp 复制代码
string user_tel;
string user_passwd;

cout << "输入登录的手机号码:" << endl;
cin >> user_tel;
cout << "输入用户密码" << endl;
cin >> user_passwd;

这部分很简单,就是从键盘把用户输入读进来。

这里你要记住一件事:

登录至少要有两个最核心的数据:

  • 你是谁

  • 你的密码是什么

所以这里用手机号标识用户,用密码做认证

第二部分:为什么要 val.clear()

cpp 复制代码
val.clear();

这一句特别容易被忽略,但其实很重要。

因为 val 是 Client 类的成员变量,不是每次进入函数重新创建的。

也就是说,如果你上一次发过注册请求,里面可能还残留着:

  • type = "ZC"

  • user_name

  • user_passwd

如果你这次登录前不清空,就可能把旧数据带进新请求里。

所以你可以把 clear() 理解成:

发新快递之前,先把旧箱子倒空。

第三部分:组装登录 JSON

cpp 复制代码
val["type"] = "DL";
val["user_tel"] = user_tel;
val["user_passwd"] = user_passwd;

这一段是客户端登录函数最核心的地方。

它构造出来的大概就是这样一个 JSON:

cpp 复制代码
{
"type": "DL",
"user_tel": "13900000000",
"user_passwd": "123456"
}

这里每个字段的含义你一定要记住:

  • type = "DL":告诉服务器,这是登录请求

  • user_tel:告诉服务器,登录的是哪个账号

  • user_passwd:告诉服务器,输入的密码是什么

这一步本质上是在做:

把用户输入的数据,整理成双方约定好的通信格式。


第四部分:发送给服务器

cpp 复制代码
string send_str = val.toStyledString();
send(sockfd, send_str.c_str(), strlen(send_str.c_str()), 0);

这里做了两件事:

第一,toStyledString()

把 JSON 对象变成字符串。

第二,send()

把这个字符串通过 socket 发给服务器。

为什么不能直接发 val?

因为 val 是 JSON 对象,不是网络能直接发送的字节流。

所以必须先把它变成字符串,再发出去。

你后面面试如果被问到,可以直接这样答:

客户端先把 JSON 对象序列化成字符串,再通过 TCP 套接字发给服务器。


第五部分:接收服务器返回结果

cpp 复制代码
val.clear();
char status_buff[128] = {0};
int num = recv(sockfd, status_buff, 127, 0);

这里又先清空了一次 val,因为前面的 val 装的是"请求包",现在要拿它来装"响应包"。

status_buff 是字符数组,用来暂时保存服务器返回的字符串。

比如服务器可能返回:

cpp 复制代码
{
"status": "OK",
"user_name": "小红"
}

或者:

cpp 复制代码
{
"status": "ERR"
}

recv() 的作用就是把服务器返回的数据收进来。


第六部分:为什么要判断 num <= 0

cpp 复制代码
if (num <= 0)
{
cout << "ser close" << endl;
return;
}

这个判断是为了防止你继续处理无效数据。

如果:

  • num == 0:一般表示服务器关闭连接

  • num < 0:一般表示接收失败

这时候你再往下解析 JSON,就没有意义了,所以要直接结束。

这就是网络程序里最基本的一种"防御式处理"。


第七部分:解析服务器返回的 JSON

cpp 复制代码
if (!read.parse(status_buff, val))
{
cout << "解析登录返回协议失败" << endl;
return;
}

这一步叫反序列化。

前面客户端发给服务器时,是把 JSON 对象变成字符串;

现在客户端收到的是字符串,所以要把字符串再变回 JSON 对象,才能用:

cpp 复制代码
val["status"]
val["user_name"]

这样的方式去取值。

你可以把这一步理解成:

把服务器寄回来的"文字包裹",重新拆成结构化数据。


第八部分:判断登录是否成功

cpp 复制代码
string status = val["status"].asString();

if (status.compare("OK") != 0)
{
cout << "登陆失败" << endl;
return;
}

这里说明客户端并不自己判断密码对不对。

客户端只做一件事:

看服务器告诉我的结果是什么。

如果服务器返回的 status 不是 "OK",客户端就认为登录失败。

这也正好体现了客户端和服务器的职责分工:

  • 客户端不做认证

  • 服务器做认证

  • 客户端只展示结果


第九部分:登录成功后为什么要保存用户名

cpp 复制代码
curr_username = val["user_name"].asString();
dl_flg = true;

这两句是登录成功之后最关键的两句。

先说第一句:

cpp 复制代码
curr_username = val["user_name"].asString();

这表示客户端把当前登录用户保存下来。

以后打印菜单时,就能显示:

cpp 复制代码
---用户名: 小红 -状态: 已登陆---

如果不保存用户名,客户端只知道"登录成功了",但不知道是谁登录的。

再说第二句:

cpp 复制代码
dl_flg = true;

这表示客户端的状态从"未登录"切换成了"已登录"。

你的 Print_Info() 是靠这个标志位来切换菜单的:

  • dl_flg == false:显示登录/注册菜单

  • dl_flg == true:显示预约/查看/取消预约/退出菜单

所以这句的本质是:

登录成功后,客户端切换到已登录状态。

二、再讲服务端:Recv_CallBack::Dl_User() 到底在做什么

服务端登录函数比客户端更重要,因为真正负责"审核账号密码是否正确"的,是服务端,不是客户端。

你可以把服务端理解成"后台审核员"。

客户端只是把资料送过来;

服务端才真正决定:

  • 这个账号存不存在

  • 这个密码对不对

  • 能不能登录成功

cpp 复制代码
void Recv_CallBack::Dl_User()
{
    // 从客户端发来的 JSON 中取出手机号和密码
    string user_tel = val["user_tel"].asString();
    string user_passwd = val["user_passwd"].asString();

    // 如果手机号或密码为空,说明请求不合法,直接返回错误
    if (user_tel.empty() || user_passwd.empty())
    {
        Send_err();
        return;
    }

    // 创建数据库客户端对象
    // 后面所有数据库操作都通过这个对象完成
    Mysql_Client mysqlcli;

    // 初始化 MySQL 环境
    if (!mysqlcli.Init())
    {
        Send_err();
        return;
    }

    // 连接数据库服务器
    if (!mysqlcli.Connect_mysql_ser())
    {
        Send_err();
        return;
    }

    // 根据手机号查询数据库中的密码和用户名
    // 这里只查询 passwd 和 name,因为登录只需要这两个字段
    string sql = string("select passwd,name from user_info where tel_id=") + user_tel;
    if (!mysqlcli.Query_sql(sql))
    {
        Send_err();
        mysqlcli.Close();
        return;
    }

    // 定义两个临时变量
    // tmp_passwd 保存数据库中查到的密码
    // tmp_username 保存数据库中查到的用户名
    string tmp_passwd;
    string tmp_username;

    // 从数据库结果集中读取用户名和密码
    // 注意顺序:第一个参数接用户名,第二个参数接密码
    if (!mysqlcli.Read_Dl_user_(tmp_username, tmp_passwd))
    {
        Send_err();
        mysqlcli.Close();
        return;
    }

    // 数据读出来之后,就可以关闭数据库连接了
    mysqlcli.Close();

    // 比较数据库中的密码和客户端传来的密码
    if (tmp_passwd.compare(user_passwd) != 0)
    {
        Send_err();
        return;
    }

    // 如果能走到这里,说明密码匹配,登录成功
    Json::Value resval;
    resval["status"] = "OK";
    resval["user_name"] = tmp_username;

    // 把登录成功结果返回给客户端
    string res_str = resval.toStyledString();
    send(c, res_str.c_str(), strlen(res_str.c_str()), 0);
}

2)服务端这段代码一块一块在干什么

第一部分:从 JSON 中取值

cpp 复制代码
string user_tel = val["user_tel"].asString();
string user_passwd = val["user_passwd"].asString();

这里的 val 是服务端在 CallBack_Fun() 里解析出来的 JSON 对象。

也就是说,客户端发来的数据,现在已经被服务端存进 val 里面了。

这里做的事情就是:

  • 把 user_tel 从 JSON 里拿出来

  • 把 user_passwd 从 JSON 里拿出来

你可以这样理解:

客户端发来的是一个"数据快递箱";
asString() 就是把箱子里的东西拿出来,变成程序里能直接用的字符串变量。

第二部分:为什么先判空

cpp 复制代码
if (user_tel.empty() || user_passwd.empty())
{
    Send_err();
    return;
}

如果手机号为空,或者密码为空,就没必要继续查数据库了。

因为这种请求从业务角度看,本身就是无效的。

用户连手机号和密码都没填完整,服务器当然不能帮你登录。

所以这一步相当于:

先把明显不合法的请求挡在门外。

第三部分:为什么要创建 Mysql_Client mysqlcli

cpp 复制代码
Mysql_Client mysqlcli;

这是今天最有课程价值的地方之一。

你们没有把所有数据库底层代码都直接写在 Dl_User() 里面,

而是专门做了一个数据库类 Mysql_Client。

这样 Dl_User() 只负责"登录流程",不负责去管太多底层细节。

它只要按顺序调用:

  • 初始化

  • 连接数据库

  • 执行 SQL

  • 读取结果

  • 关闭连接

这就叫"封装"。

封装的好处是:业务代码更清晰,更容易维护。

第四部分:数据库三连

cpp 复制代码
if (!mysqlcli.Init())
{
Send_err();
return;
}

if (!mysqlcli.Connect_mysql_ser())
{
Send_err();
return;
}

string sql = string("select passwd,name from user_info where tel_id=") + user_tel;
if (!mysqlcli.Query_sql(sql))
{
Send_err();
mysqlcli.Close();
return;
}

这一段可以看成数据库三连:

第一连:Init()

初始化 MySQL 客户端环境。

底层对应的是:

cpp 复制代码
mysql_init(&mysql_con)

它的作用是先把数据库对象准备好。

就像打电话之前,先拿起电话。

第二连:Connect_mysql_ser()

连接数据库服务器。

底层对应的是:

cpp 复制代码
mysql_real_connect(...)

只有真正连上数据库,SQL 才能执行。

第三连:Query_sql(sql)

执行 SQL。

底层对应的是:

cpp 复制代码
mysql_query(&mysql_con, sql.c_str())

注意,这一步只是让 SQL 去执行,并不代表结果已经被你拿到手了。


第五部分:为什么 SQL 只查 passwd,name

cpp 复制代码
string sql = string("select passwd,name from user_info where tel_id=") + user_tel;

因为登录只需要解决两个问题:

  1. 这个手机号对应的密码是什么

  2. 这个手机号对应的用户名是什么

所以没必要把整张表全查出来。

只查你现在最需要的两个字段就够了:

  • passwd:后面用来比对密码

  • name:登录成功后返回给客户端显示

这一步你一定要记住一句话:

登录不是 insert,登录本质是 select 校验。

注册才是 insert。


第六部分:为什么还要定义临时变量

cpp 复制代码
string tmp_passwd;
string tmp_username;

这两个变量不是客户端发来的数据。

它们是用来保存"数据库查出来的数据"的。

你要分清楚两套数据:

客户端传来的:

  • user_tel

  • user_passwd

数据库查出来的:

  • tmp_passwd

  • tmp_username

后面真正做密码校验时,比的是:

cpp 复制代码
tmp_passwd 和 user_passwd

也就是:

数据库里的正确密码

和
客户端这次输入的密码


第七部分:从结果集中真正读出数据

cpp 复制代码
if (!mysqlcli.Read_Dl_user_(tmp_username, tmp_passwd))
{
Send_err();
mysqlcli.Close();
return;
}

这一步是今天最关键的一步之一。

前面你执行了 SQL,

但执行 SQL 只是"让数据库去查"。

查完的结果还放在数据库结果集里,你必须再把它取出来。

Read_Dl_user_() 干的,就是这件事:

把结果集中的用户名和密码读出来,放进 tmp_username 和 tmp_passwd。

这里你今天踩到的最大坑也在这里:

这个函数定义是:

cpp 复制代码
bool Read_Dl_user_(string &uname, string &upasswd)

所以:

  • 第一个参数接用户名

  • 第二个参数接密码

因此调用必须写成:

cpp 复制代码
mysqlcli.Read_Dl_user_(tmp_username, tmp_passwd)

不能写反。

如果写反,就会把用户名塞进密码变量里,把密码塞进用户名变量里,后面密码比较一定失败。


第八部分:为什么读完结果就可以关闭数据库

cpp 复制代码
mysqlcli.Close();

因为这个时候数据库里的结果已经被你取出来,放进本地变量了:

  • tmp_passwd

  • tmp_username

所以数据库连接的任务已经完成,可以关闭了。

这一步的本质是:

释放资源,避免数据库连接一直占着不用。


第九部分:密码比较

cpp 复制代码
if (tmp_passwd.compare(user_passwd) != 0)
{
Send_err();
return;
}

这句是服务端登录逻辑的核心判断。

  • tmp_passwd:数据库中查出来的正确密码

  • user_passwd:客户端传来的密码

如果不相等,就说明用户输入错了密码,服务器直接返回错误。

这一句最能体现"认证逻辑在服务端,不在客户端"。


第十部分:登录成功后构造响应 JSON

cpp 复制代码
Json::Value resval;
resval["status"] = "OK";
resval["user_name"] = tmp_username;

string res_str = resval.toStyledString();
send(c, res_str.c_str(), strlen(res_str.c_str()), 0);

这一步就是服务端把结果告诉客户端。

构造出来的 JSON 大概是:

cpp 复制代码
{
"status": "OK",
"user_name": "小红"
}

客户端收到后就知道:

  • 登录成功了

  • 当前登录用户叫"小红"

所以服务端不只是返回"成功/失败",还顺便把用户名返回给客户端。

这样客户端才能更新 curr_username。


三、今天最重要的一部分:数据库封装函数 Read_Dl_user_() 怎么理解

这个函数非常值得你单独记,因为它是第五节课的重点之一。


1)先看完整代码

cpp 复制代码
bool Mysql_Client::Read_Dl_user_(string &uname, string &upasswd)
{
// 从 mysql_con 中取出 SQL 执行后的结果集
MYSQL_RES *r = mysql_store_result(&mysql_con);
if (r == NULL)
{
return false;
}

// 判断结果集中有没有数据
int num = mysql_num_rows(r);
if (num == 0)
{
mysql_free_result(r);
return false;
}

// 判断字段数是否正确
// 这里只查询了 passwd 和 name,所以列数应该是 2
int count = mysql_field_count(&mysql_con);
if (count != 2)
{
mysql_free_result(r);
return false;
}

// 取出一行结果
MYSQL_ROW row = mysql_fetch_row(r);
if (row == NULL)
{
mysql_free_result(r);
return false;
}

// SQL 是:select passwd,name ...
// 所以 row[0] 是 passwd,row[1] 是 name
upasswd = row[0];
uname = row[1];

// 释放结果集
mysql_free_result(r);
return true;
}

这就是你们今天新增的"读取登录结果"的函数。


2)这一段到底在干什么

第一步:mysql_store_result(&mysql_con)

cpp 复制代码
MYSQL_RES *r = mysql_store_result(&mysql_con);

前面的 Query_sql(sql) 只是执行了 SQL。

真正的查询结果,还在 MySQL 内部。

mysql_store_result() 的作用就是:

把数据库查到的结果集拿出来,交给程序。

你可以理解成:

  • Query_sql() 是让数据库去仓库找东西

  • mysql_store_result() 是把找到的东西真正搬出来


第二步:判断有没有查到记录

cpp 复制代码
int num = mysql_num_rows(r);
if (num == 0)
{
mysql_free_result(r);
return false;
}

如果结果集里一行都没有,说明这个手机号查不到对应用户。

那当然就登录失败了。

所以这一步就是判断:

数据库里到底有没有这个人。


第三步:判断字段数是不是 2

cpp 复制代码
int count = mysql_field_count(&mysql_con);
if (count != 2)
{
mysql_free_result(r);
return false;
}

因为你的 SQL 写的是:

cpp 复制代码
select passwd,name from user_info where tel_id=...

所以理论上结果集应该有两列:

  • 第 0 列:passwd

  • 第 1 列:name

如果字段数不是 2,说明查询结果不符合预期,那程序就不能往下继续用了。

这一句体现的其实是"防御式编程":

不要默认数据库一定返回你想要的格式,而是先检查。


第四步:取出一行数据

cpp 复制代码
MYSQL_ROW row = mysql_fetch_row(r);

这里是从结果集里取一行。

登录时用手机号查用户,正常情况下应该只会查到一条记录。

所以取第一行就够了。


第五步:为什么要写成 upasswd = row[0]; uname = row[1];

cpp 复制代码
upasswd = row[0];
uname = row[1];

因为你的 SQL 是:

cpp 复制代码
select passwd,name from user_info ...

字段顺序决定了:

  • row[0] 对应 passwd

  • row[1] 对应 name

所以代码必须写成:

cpp 复制代码
upasswd = row[0];
uname = row[1];

不是随便写的。

这也是你们今天出 bug 的根源之一:
顺序不能乱。


第六步:为什么最后一定要 mysql_free_result(r)

cpp 复制代码
mysql_free_result(r);

结果集占用了内存。

如果不释放,就会造成资源浪费,时间长了可能出问题。

这就和 C/C++ 里"谁申请、谁释放"的思想很像。

所以读完结果之后,要记得释放。


四、你现在把这三段代码串起来,整个登录流程就清楚了

客户端侧

Dl_user() 做的事是:

用户输入手机号和密码

→ 封装成登录 JSON

→ 发给服务器

→ 接收服务器返回结果

→ 判断 status

→ 保存用户名

→ 更新登录状态。

服务端侧

Dl_User() 做的事是:

从 JSON 中取手机号和密码

→ 判空

→ 创建数据库对象

→ 初始化并连接数据库

→ 执行查询 SQL

→ 调用 Read_Dl_user_() 读出用户名和密码

→ 比较数据库密码和客户端密码

→ 构造成功 JSON 返回给客户端。

数据库封装层

Read_Dl_user_() 做的事是:

拿结果集

→ 判断有没有数据

→ 判断列数对不对

→ 取一行

→ 把密码和用户名赋值出来

→ 释放结果集。


五、最适合你复习的版本

下面这版你可以直接背。

复习版

客户端登录函数 Dl_user() 的作用,是把用户输入的手机号和密码封装成 type="DL" 的 JSON 请求,通过 socket 发送给服务器,然后接收服务器返回的 JSON 响应,并根据 status 判断登录是否成功。如果成功,客户端会保存服务器返回的用户名到 curr_username,同时把 dl_flg 设为 true,表示当前已经登录。

服务端登录函数 Dl_User() 的作用,是从客户端发来的 JSON 中取出手机号和密码,检查参数是否为空,然后通过 Mysql_Client 数据库对象完成 MySQL 初始化、数据库连接和 SQL 查询。查询语句根据手机号从 user_info 表中读取密码和用户名,再调用 Read_Dl_user_() 把结果集中的数据提取出来,最后将数据库密码与客户端输入密码进行比较。如果一致,就构造登录成功的 JSON 响应并返回给客户端;如果不一致,就返回错误状态。

数据库封装函数 Read_Dl_user_() 的作用,是把 SQL 查询后的结果集真正读出来。它先通过 mysql_store_result() 取得结果集,再检查结果集是否为空、字段数是否正确,然后用 mysql_fetch_row() 取出一行数据,并将 row[0] 赋给密码、row[1] 赋给用户名,最后释放结果集。这个函数的意义在于把数据库底层读取逻辑从业务函数中拆分出来,让 Dl_User() 只关注登录流程本身。


六、最后给你一个"死记版"

  1. 客户端负责输入、发送、接收、展示。

  2. 服务端负责解析、查库、校验、返回。

  3. 登录请求的 type 是 "DL"。

  4. 登录本质不是 insert,而是 select 校验。

  5. Read_Dl_user_() 是用来读取数据库结果集的。

  6. row[0] 是密码,row[1] 是用户名。

  7. 调用 Read_Dl_user_() 时,参数顺序不能写反。

你下一条要是愿意,我可以继续把这三段代码整理成一份**"逐行注释终极版"**,也就是你可以直接抄进笔记里的那种格式。

五、本次过程中我们还遇到了一个小问题:登录失败

这次登录失败,真正的根本原因不是 JSON 组装错了,也不是 SQL 语句错了,也不是客户端收包有问题,而是服务端把"密码"和"用户名"这两个变量接反了 。

数据库读出来后,本来应该是:

  • tmp_passwd = "111111"

  • tmp_username = "小红"

但实际因为参数顺序/变量名写反,变成了:

  • tmp_passwd = "小红"

  • tmp_usename = "111111"

所以后面执行:

cpp 复制代码
if(tmp_passwd.compare(user_passwd) != 0)

实际上比的是:

cpp 复制代码
"小红" 和 "111111"

当然不相等,所以服务端一直返回 ERR。

PPT上的一句话总结

本次登录失败的根因:读取数据库结果后,用户名和密码接收顺序写反,导致服务端把用户名当成密码去比较。

一、先把这次排错全过程按时间线梳理出来

你们这次其实做了一次很标准的排错,顺序非常好。

我按你描述的过程,帮你整理成 7 个阶段。


阶段1:先遇到编译报错 ------ "没有这个成员"

对应你说的第1张图。

报错大意是:

  • no declaration matches 'bool Mysql_Client::Read_dl_user(...)'

  • class Mysql_Client has no member named 'Read_dl_user'


你们当时的怀疑

你们第一反应是:

  • 是不是函数加错位置了

  • 是不是 server.h 里没声明

  • 是不是文件没保存

这个怀疑是对的,因为这种报错本质上就是:

cpp 里实现了一个函数,但头文件里没有对应声明,或者声明和定义不一致。


这一步你们怎么解决的

你们后来保存了一次,再编译通过了。

说明这里大概率是下面两种情况之一:

情况1:头文件没保存

server.cpp 里已经写了:

cpp 复制代码
bool Mysql_Client::Read_dl_user(...)

但 server.h 里还没有刷新保存,所以编译器看到的类定义里没有这个成员。

情况2:声明和定义签名不一致

比如头文件里可能没写,或者参数列表和 cpp 中写的不一样。


这一阶段你要记住什么

遇到"类中没有这个成员"时,先查头文件声明,再查 cpp 定义,再检查是否保存。


阶段2:程序能编译了,但运行时登录失败

对应你说的第4张图。

现象是:

  • 用"小红"登录

  • 手机号和密码都输入对了

  • 客户端却提示:登录失败

这说明问题已经从"编译错误"转到了"运行逻辑错误"。


这一阶段你们的判断思路

你们没有马上认定是数据库错了,而是先怀疑:

  • 是不是报文组装错了

  • 是不是客户端发错了

  • 是不是服务端解析错了

  • 是不是 SQL 查出来的数据不对

  • 是不是客户端收到的返回包不对

这个思路非常对,因为运行时问题不能靠猜,必须一层层排除。


阶段3:先检查数据库和 SQL 是否正常

对应你说的第5张图,还有数据库查询图。

你们去看了数据库内容,执行了类似:

cpp 复制代码
select passwd,name from user_info where tel_id=13900000001;

查到结果是:

  • passwd = 111111

  • name = 小红


这一阶段说明了什么

至少说明两件事:

第一,数据库里确实有这条用户记录

不是因为用户不存在而失败。

第二,SQL 逻辑方向没错

根据手机号查密码和用户名,这个思路是通的。


这一步为什么重要

因为一旦数据库内容和 SQL 本身都对,就可以先把"大概率数据库查不到"这个方向排掉。

也就是说,你们已经把问题范围缩小到了:

  • 数据库查询结果"取出来时"出错

  • 或者服务端"比对逻辑"出错

  • 或者服务端"返回包"出错


阶段4:给服务端和客户端都加打印,先看数据流到底走到哪一步

这一阶段对应你说的:

  • 图6 :服务端加 cout<<user_tel<<endl; cout<<user_passwd<<endl;

  • 图7 :客户端加 cout<<status_buff<<endl;

这是非常典型、非常实用的排错方法。


4.1 服务端打印输入参数

你们在 Dl_User() 里加了:

cpp 复制代码
cout << user_tel << endl;
cout << user_passwd << endl;

目的很明确:

验证服务端从 JSON 中取出的手机号和密码,到底对不对。

你们当时怀疑这一段:

cpp 复制代码
string user_tel = val["user_tel"].asString();
string user_passwd = val["user_passwd"].asString();
if (user_tel.empty() || user_passwd.empty())
{
Send_err();
return;
}

是不是 empty() 提前返回了,导致后面的逻辑根本没执行。


4.2 为什么会怀疑 empty()

因为你们发现打印语句似乎没有输出,于是怀疑:

  • val["user_tel"] 没拿到值

  • val["user_passwd"] 没拿到值

  • empty() 判空后直接 Send_err(); return;

这个怀疑也很合理。


4.3 客户端打印接收到的状态包

客户端加:

cpp 复制代码
cout << status_buff << endl;

目的是看:

客户端到底收到了什么。

结果客户端打印出来的是:

cpp 复制代码
{
"status" : "ERR"
}

这一步得出了什么结论

这一步很关键,说明:

  • 客户端能正常收到服务端返回的数据

  • 返回包格式也基本没问题

  • 问题不是客户端解析错了

  • 而是服务端本身就返回了 ERR

所以问题大概率在服务端。


阶段5:从服务端入口重新梳理登录调用链

这一阶段对应你说的图9、图10、图11、图12。

你们不是只盯着 Dl_User(),而是从更上层开始看,这是非常好的思路。


5.1 看客户端发给服务端的数据是否正常

从图9看,客户端发出的登录数据是正常的:

  • type = "DL"

  • user_tel = "13900000001"

  • user_passwd = "111111"

说明客户端发包没问题。


5.2 看服务端 recv 是否正常收到数据

对应图10里:

cpp 复制代码
cout << buff << endl;

打印出来的 JSON 没问题。

说明:

  • socket 收包正常

  • 原始字符串没问题


5.3 看服务端是否成功反序列化并进入登录逻辑

服务端做了:

cpp 复制代码
reader.parse(buff, val);
cout << val["type"].asString() << endl;

然后通过:

cpp 复制代码
map<string, enum Cho_Type>::iterator pos = cho_table.find(val["type"].asString());
switch (cho_type)
{
case DL:
Dl_User();
break;
}

顺利进入了 Dl_User()。

这说明:

  • JSON 反序列化没问题

  • type 字段没问题

  • 业务分发没问题


这一阶段排除了什么

到这里你们已经排除了:

  • 客户端发包错

  • socket 收包错

  • JSON 解析错

  • 协议分发错

问题就被缩小到了:

Dl_User() 内部逻辑错

这一步非常重要,因为排错最怕乱找,而你们已经把问题范围压缩得很小了。


阶段6:图形化调试失败,于是改为手动 GDB 调试

这一阶段对应你说的图13、图14。


6.1 图形化调试为什么失败

你们本来想让系统自动生成调试配置文件,用图形方式调试,但失败了。

然后手动启动 GDB 时,一开始又写成了:

复制代码
gdb service

这就导致 GDB 提示:

  • /usr/sbin/service: not in executable format

  • No symbol table is loaded


这里的问题是什么

你调试的是自己的程序 server,不是系统命令 service。

也就是说,真正应该调试的是:

gdb server

而不是:

gdb service


这一阶段要记住什么

GDB 后面跟的是你自己编译出来的可执行文件名,不是 Linux 系统命令。


阶段7:手动 GDB 单步调试,最终定位根因

这一段是最重要的。

你要求我细致讲 GDB,我就按你们这次真实过程,一步一步讲。


二、GDB 调试全过程,一步一步讲


第一步:进入 GDB

正确命令:

gdb server

进入后,GDB 会读取可执行文件中的符号信息。

如果程序是带调试信息编译的,后面就能看到代码行号、变量名。

更规范一点,平时最好这样编译:

g++ -g -o server server.cpp -levent -ljsoncpp -lmysqlclient

这里 -g 就是加入调试信息。


第二步:查看代码位置

你们用了:

l

这是 list,意思是列出当前代码附近的源码。

后面又用了:

l 233

表示查看第 233 行附近的代码。


第三步:在关键位置下断点

你们在 Recv_CallBack::CallBack_Fun() 里下断点,大概是:

b 236

意思是在第 236 行设置断点。

这一行是:

int n = recv(c, buff, 127, 0);


为什么断在这里

因为你们想从"服务端收到客户端请求的入口"开始看,确认:

  • 请求有没有收到

  • 数据是什么

  • 后面有没有顺利进入 Dl_User()

这就是从入口开始追踪,很标准。


第四步:运行程序

命令:

r

也就是 run。

程序开始跑,客户端这时发起登录请求。

当代码执行到断点处时,GDB 停住。


第五步:单步往下执行

你们用的是:

n

n = next

意思是:

执行当前行,然后停在下一行,但不进入函数内部。

这个非常适合你们这种"想看流程一步步怎么走"的场景。


第六步:打印变量,确认服务端拿到的手机号和密码

你们执行了:

p user_tel

p user_passwd

输出大概是:

$3 = "13900000001"

$4 = "111111"


这一步说明什么

说明:

  • val["user_tel"].asString() 取值成功

  • val["user_passwd"].asString() 取值成功

  • empty() 判断没有误伤

  • 这一段代码本身没有问题

也就是说,你们之前怀疑的:

cpp 复制代码
string user_tel = val["user_tel"].asString();
string user_passwd = val["user_passwd"].asString();
if (user_tel.empty() || user_passwd.empty())

并不是问题根源。


第七步:继续单步执行数据库相关逻辑

你们继续用:

n

一步步往下走,经过了这些位置:

  • Mysql_Client mysqlcli;

  • mysqlcli.Init();

  • mysqlcli.Connect_mysql_ser();

  • 构造 SQL

  • mysqlcli.Query_sql(sql);

  • 定义临时变量

  • mysqlcli.Read_dl_user(...)

  • mysqlcli.Close();


这一步你们在验证什么

验证的是:

  • 数据库初始化有没有出错

  • 数据库连接有没有出错

  • SQL 执行有没有出错

  • 读取结果有没有走通

而从你们的调试过程来看,这些步骤都走过去了,没有提前 Send_err() 返回。

说明数据库操作整体是通的。


第八步:在密码比较前,打印关键变量

这一步就是你们这次排错的决定性一步。

你们在执行到:

cpp 复制代码
if(tmp_passwd.compare(user_passwd) != 0)

之前,用 GDB 打印了:

cpp 复制代码
p tmp_passwd
p tmp_usename

结果看到:

cpp 复制代码
tmp_passwd = "小红"
tmp_usename = "111111"

这一步说明了什么

这一步直接把问题钉死了:

本来应该是:

  • tmp_passwd = "111111"

  • tmp_username = "小红"

但实际变成了:

  • tmp_passwd = "小红"

  • tmp_usename = "111111"

这说明:

数据库查出来的数据读到了,但接收位置写反了。

也就是说,不是没查到,而是查到了以后放错变量了。


第九步:为什么会写反

这里通常有两个可能原因,你们这次其实两个都沾上了。

原因1:Read_dl_user() 参数顺序和调用顺序不一致

比如函数定义是:

cpp 复制代码
bool Read_dl_user(string &uname, string &upasswd)

意思是:

  • 第一个参数接用户名

  • 第二个参数接密码

但你调用的时候写成:

cpp 复制代码
mysqlcli.Read_dl_user(tmp_passwd, tmp_usename)

这就变成:

  • tmp_passwd 接到了用户名

  • tmp_usename 接到了密码


原因2:变量名本身也有拼写问题

你前面一直说 tmp_username,但调试时看到的是:

cpp 复制代码
p tmp_username
No symbol "tmp_username" in current context.
p tmp_usename
$6 = "111111"

说明你代码里实际写的是:

cpp 复制代码
string tmp_usename;

少了一个 r。

也就是说,不只是顺序写反,变量名也不统一,增加了排错难度。


第十步:为什么一定会返回 ERR

因为后面判断是:

cpp 复制代码
if(tmp_passwd.compare(user_passwd) != 0)
{
Send_err();
return;
}

而此时实际值是:

  • tmp_passwd = "小红"

  • user_passwd = "111111"

所以比较结果当然不相等,程序就进入:

cpp 复制代码
Send_err();
return;

然后客户端就收到:

cpp 复制代码
{
"status" : "ERR"
}

这就和你们客户端看到的现象完全对上了。


三、这次排错中,每一步"为什么这样做"你要搞清楚

下面这部分是最适合你后面复习和面试说的。


1. 为什么先查编译错误

因为程序根本跑不起来时,先解决"能不能编译"问题。

没有必要在编译不过的时候去想业务逻辑。


2. 为什么运行失败后先怀疑报文组装

因为登录链路跨了多个模块:

  • 客户端组包

  • socket 发送

  • 服务端收包

  • JSON 解析

  • 业务分发

  • 数据库查询

  • 返回响应

  • 客户端解析响应

任何一步都可能出错,所以先从整体链路去想是对的。


3. 为什么先查数据库和 SQL

因为数据库查询是登录认证的核心。

如果数据库里没有这个用户,或者 SQL 写错了,那后面所有调试都白费。


4. 为什么客户端和服务端都加打印

因为网络程序的问题常常出在"你以为发的是 A,实际收到的是 B"。

所以:

  • 服务端打印是为了看"收到的请求"

  • 客户端打印是为了看"收到的响应"

这样就能判断问题到底在请求链路还是响应链路。


5. 为什么最终还要用 GDB

因为 cout 只能看到"某一步的值",

但 GDB 可以看到:

  • 程序到底走到了哪一行

  • 哪一步进入了哪个分支

  • 某个变量在当前时刻到底是什么

  • 比较语句前的值到底是什么

这对于定位"逻辑没错但变量串了"的问题,特别有效。


四、这次 GDB 调试过程,适合你背诵的标准说法

你面试时可以这么讲:

面试回答模板

这次登录失败后,我没有直接猜测是数据库或者 JSON 有问题,而是先按链路逐层排查。首先确认客户端发送的登录 JSON 正常,服务端也能正确接收到并解析 type=DL,说明网络通信和 JSON 协议没有问题。然后我查看数据库内容和 SQL 查询结果,确认数据库中确实存在该用户,且查询语句能够正确返回密码和用户名。接着,我在客户端打印收到的响应包,发现服务端返回的是 ERR,于是确定问题在服务端内部逻辑。由于图形化调试失败,我改用手动 GDB 调试:先用 gdb server 加载可执行文件,在 Recv_CallBack::CallBack_Fun() 入口下断点,通过 run 运行程序,再用 next 单步执行,用 print 查看 user_tel、user_passwd、tmp_passwd 和 tmp_usename 等关键变量。最终在密码比较前发现 tmp_passwd 保存的竟然是用户名"小红",而 tmp_usename 保存的是密码"111111",说明读取数据库结果后,用户名和密码的接收顺序写反了。这个错误直接导致服务端把用户名当密码比较,因此总是返回 ERR。最终定位问题后,只需要把参数顺序改正确,登录逻辑就能正常工作。


五、这次问题的根本原因,帮你彻底讲透

你这次问题其实不是"数据库没查到",而是查到了以后接错位置。


正确的数据流应该是

数据库查出来一行:

  • row[0] = passwd = "111111"

  • row[1] = name = "小红"

然后应该赋值成:

tmp_passwd = row0;

tmp_username = row1;

也就是:

  • tmp_passwd = "111111"

  • tmp_username = "小红"


你们实际变成了

  • tmp_passwd = "小红"

  • tmp_usename = "111111"

所以密码比较逻辑失效。


本质上这是哪类错误

这是一个典型的:

参数顺序错误 + 变量命名不统一错误

这类错误特别容易出现在:

  • 自己封装函数时

  • 形参和实参顺序不一致时

  • 变量名写得太像时


六、这次排错带给你的经验,后面一定要记住


经验1:类成员函数加了以后,先检查头文件声明

出现"没有这个成员"时,第一件事不是怀疑编译器,而是检查:

  • .h 里有没有声明

  • .cpp 里定义是否一致

  • 文件是否保存


经验2:网络程序出错,先区分"请求错了"还是"响应错了"

客户端打印请求和响应,服务端打印收包和关键变量,能迅速判断问题在哪一侧。


经验3:数据库问题不能只看 SQL,还要看"查出来后放到了哪个变量"

很多初学者查到结果就以为没问题了,但真正的问题可能在"结果怎么赋值"。


经验4:变量名一定要统一

tmp_username 和 tmp_usename 这种拼写不统一,非常容易把自己绕进去。


经验5:GDB 最适合定位"逻辑没崩但结果不对"的问题

像这次这种:

  • 程序能跑

  • 也没崩

  • 但结果总错

这就是 GDB 特别适合的场景。


七、这次 GDB 命令你后面复习时可以直接记这一套

下面是适合你这种项目初学阶段的最小调试流程。

1. 带调试信息编译

g++ -g -o server server.cpp -levent -ljsoncpp -lmysqlclient

2. 进入 GDB

gdb server

3. 查看代码

l

l 233

4. 下断点

b 236

5. 运行程序

r

6. 单步执行

n

7. 打印变量

p user_tel

p user_passwd

p tmp_passwd

p tmp_usename

8. 退出

q


八、给你一版适合后期复习的"全过程总结"

结论展示风格

本次登录失败问题的排查过程经历了"编译错误处理---运行时现象确认---数据库与 SQL 验证---客户端/服务端打印排查---手动 GDB 单步调试---最终定位变量接反"几个阶段。首先,程序在新增 Read_dl_user 后出现"类中没有该成员"的编译错误,经过检查头文件声明和文件保存后解决。随后程序虽然能运行,但使用"小红"登录时客户端始终收到 ERR 并提示登录失败。为了定位问题,先检查数据库中 user_info 表,确认手机号 13900000001 对应的密码和用户名确实存在,SQL 查询语句也能正确返回数据。接着在客户端打印收到的响应包,发现服务端返回的是 ERR,从而判断问题主要在服务端。之后又从服务端入口 CallBack_Fun() 开始检查,请求包接收、JSON 解析、协议分发到 Dl_User() 的过程都正常。由于图形化调试失败,转而使用手动 GDB 调试:在关键代码行设置断点,逐行执行并打印变量值,最终在密码比较前发现 tmp_passwd 中保存的是用户名"小红",而 tmp_usename 中保存的是密码"111111",说明数据库结果读取后的参数顺序写反了,同时变量命名也不统一。正因为如此,程序把用户名当成密码去比较,导致比较失败并返回 ERR。因此,本次问题的根因并不在通信或数据库查询本身,而在于服务端读取数据库结果后的变量接收顺序错误。

PPT上的一句话总结

本次排错的关键:通过客户端/服务端打印和 GDB 单步调试,最终定位到"数据库读取结果后用户名和密码写反"这一根因。


九、你后面整理笔记时,图片顺序建议这样放

你这次这一组图,建议在笔记中按下面顺序放:

  1. 图1 :编译报错,没有 Read_dl_user 成员

  2. 图2:预期的登录成功/失败返回报文格式

  3. 图3 :数据库中 SQL 查询结果,确认 passwd 和 name 正常

  4. 图4:运行时登录失败现象

  5. 图5:服务端和数据库界面,验证表中数据和输入信息

  6. 图6 :在 Dl_User() 中加 cout 打印 user_tel 和 user_passwd

  7. 图7 :客户端打印 status_buff,看到收到的是 ERR

  8. 图8 :服务端 cout << buff << endl;,确认原始请求包没问题

  9. 图9 :协议分发 switch-case,确认进入 Dl_User()

  10. 图10 :Dl_User() 开头代码,准备检查取值

  11. 图11 :错误使用 gdb service,图形化/自动调试失败后转手动 GDB

  12. 图12:正确进入 GDB,查看代码位置

  13. 图13 :客户端发起登录,服务端断在 recv 位置

  14. 图14 :GDB 中打印 user_tel、user_passwd,确认服务端取值正常

  15. 图15:GDB 单步执行到数据库查询与读取结果处

  16. 图16 :GDB 打印发现 tmp_passwd = "小红",tmp_usename = "111111"

  17. 图17 :继续单步执行后进入 Send_err()

  18. 图18:退出 GDB,确定根因已经定位


十、最后帮你提炼成 4 句必须记住的话

第一句:
编译报"没有这个成员"时,先查头文件声明、定义签名和保存状态。

第二句:
客户端收到 ERR,说明问题大概率在服务端,而不是客户端解析。

第三句:
GDB 的核心不是"高级",而是能在关键行停下来,看变量在那一刻到底是什么。

第四句:
这次登录失败的真正原因,是数据库结果读取后把用户名和密码放反了。

你下一条要是愿意,我可以把这整次"第五节课登录排错"整理成一份最终版复习笔记 ,直接按你要的格式做成:问题现象 → 排查思路 → GDB 步骤 → 根因 → 修正点 → 面试怎么说。

相关推荐
不悔哥11 分钟前
Linux系统编程多线程程序如何安全退出
linux
Fcy64824 分钟前
Linux下 数据链路层详解(从以太网到ARP协议)
linux·mac·以太网·数据链路层·arp
集智飞行1 小时前
解决mavros2 ros2版本cpu占用高的问题
java·服务器·前端
Java后端的Ai之路1 小时前
昇腾NPU显存管理避坑指南:从npu-smi输出精准识别空闲卡的底层逻辑与实战
服务器·网络·数据库·npu·大模型服务
ajassi20002 小时前
AI语音智能体开发日记(十八)智能体服务器xiaozhi-esp32-server源码部署指南
运维·服务器·人工智能·ai·ai编程
‎ദ്ദിᵔ.˛.ᵔ₎2 小时前
linux 进程信号(下)
linux·运维·服务器
skywalk81633 小时前
在FreeBSD系统的linux仿真环境下安装和使用kiro 这个亚马逊的AI agent
linux·人工智能·freebsd·kiro
Mikko73 小时前
Linux 磁盘满了但 du 找不到?LVM 扩容 df 不变?fstab 写错开不了机?三台真机实测磁盘排查
linux·运维·centos
yumgpkpm3 小时前
Acceldata ODP 3.3.6.4 vs CDP Private Cloud Base 7.3.2 对比
大数据·运维·服务器·hadoop·华为·zookeeper·hbase
Madison-No73 小时前
多语言聊天大模型--测试报告
linux·git·python·selenium·jmeter·自动化·postman