一、先讲客户端: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;
因为登录只需要解决两个问题:
-
这个手机号对应的密码是什么
-
这个手机号对应的用户名是什么
所以没必要把整张表全查出来。
只查你现在最需要的两个字段就够了:
-
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() 只关注登录流程本身。
六、最后给你一个"死记版"
-
客户端负责输入、发送、接收、展示。
-
服务端负责解析、查库、校验、返回。
-
登录请求的
type是"DL"。 -
登录本质不是
insert,而是select校验。 -
Read_Dl_user_()是用来读取数据库结果集的。 -
row[0]是密码,row[1]是用户名。 -
调用
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 = row[0];
tmp_username = row[1];
也就是:
-
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 :编译报错,没有
Read_dl_user成员 -
图2:预期的登录成功/失败返回报文格式
-
图3 :数据库中 SQL 查询结果,确认
passwd和name正常 -
图4:运行时登录失败现象
-
图5:服务端和数据库界面,验证表中数据和输入信息
-
图6 :在
Dl_User()中加cout打印user_tel和user_passwd -
图7 :客户端打印
status_buff,看到收到的是ERR -
图8 :服务端
cout << buff << endl;,确认原始请求包没问题 -
图9 :协议分发
switch-case,确认进入Dl_User() -
图10 :
Dl_User()开头代码,准备检查取值 -
图11 :错误使用
gdb service,图形化/自动调试失败后转手动 GDB -
图12:正确进入 GDB,查看代码位置
-
图13 :客户端发起登录,服务端断在
recv位置 -
图14 :GDB 中打印
user_tel、user_passwd,确认服务端取值正常 -
图15:GDB 单步执行到数据库查询与读取结果处
-
图16 :GDB 打印发现
tmp_passwd = "小红",tmp_usename = "111111" -
图17 :继续单步执行后进入
Send_err() -
图18:退出 GDB,确定根因已经定位
十、最后帮你提炼成 4 句必须记住的话
第一句:
编译报"没有这个成员"时,先查头文件声明、定义签名和保存状态。
第二句:
客户端收到 ERR,说明问题大概率在服务端,而不是客户端解析。
第三句:
GDB 的核心不是"高级",而是能在关键行停下来,看变量在那一刻到底是什么。
第四句:
这次登录失败的真正原因,是数据库结果读取后把用户名和密码放反了。
你下一条要是愿意,我可以把这整次"第五节课登录排错"整理成一份最终版复习笔记 ,直接按你要的格式做成:问题现象 → 排查思路 → GDB 步骤 → 根因 → 修正点 → 面试怎么说。