服务器预约系统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();

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

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

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

  • 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_usernametmp_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_teluser_passwdtmp_passwdtmp_usename 等关键变量。最终在密码比较前发现 tmp_passwd 保存的竟然是用户名"小红",而 tmp_usename 保存的是密码"111111",说明读取数据库结果后,用户名和密码的接收顺序写反了。这个错误直接导致服务端把用户名当密码比较,因此总是返回 ERR。最终定位问题后,只需要把参数顺序改正确,登录逻辑就能正常工作。


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

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


正确的数据流应该是

数据库查出来一行:

  • row[0] = passwd = "111111"

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

然后应该赋值成:

tmp_passwd = row[0];

tmp_username = row[1];

也就是:

  • tmp_passwd = "111111"

  • tmp_username = "小红"


你们实际变成了

  • tmp_passwd = "小红"

  • tmp_usename = "111111"

所以密码比较逻辑失效。


本质上这是哪类错误

这是一个典型的:

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

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

  • 自己封装函数时

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

  • 变量名写得太像时


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


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

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

  • .h 里有没有声明

  • .cpp 里定义是否一致

  • 文件是否保存


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

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


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

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


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

tmp_usernametmp_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 查询结果,确认 passwdname 正常

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

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

  6. 图6 :在 Dl_User() 中加 cout 打印 user_teluser_passwd

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

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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

相关推荐
江西省遂川县常驻深圳大使1 天前
openclaw.json配置示例
服务器·json·openclaw
gjc5921 天前
零基础OceanBase数据库入门(4):创建MySQL模式数据库
数据库·mysql·oracle·oceanbase
飞Link1 天前
深度掌控 Agent 调试:LangGraph 本地服务器与 Studio 核心指南
运维·服务器·jvm
古月方枘Fry1 天前
三层交换+单臂路由+ACL网络配置
服务器·网络·智能路由器
驾驭人生1 天前
ASP.NET Core 实现 SSE 服务器推送|生产级实战教程(含跨域 / Nginx / 前端完整代码)
服务器·前端·nginx
KOYUELEC光与电子努力加油1 天前
JAE日本航空电子推出满足汽车市场小型防水最新需求的MX80系列连接器
服务器·科技·单片机·汽车
jackiehome1 天前
SQL数据库无法操作,日志文件损坏修复
数据库·sql·oracle
123过去1 天前
hashid使用教程
linux·网络·测试工具·安全
荒川之神1 天前
ORACLE导入导出实验
数据库·oracle
C+++Python1 天前
Linux/C++多进程
linux·运维·c++