一、这节课干了什么:从"前台程序"到"系统服务"
前两篇咱们把网络计算器写通了:lesson55 搭骨架(模板方法 + JSON 工具),lesson56 填协议(序列化 + 报文分隔 + 完整收发)。但 lesson56 的服务器有个使用上的硬伤------它是个前台程序:
cpp
$ ./tcpserver 8080
# 终端被占住,啥也干不了
# 一关终端 / 一断 SSH,服务器跟着就死了
真正的服务器(nginx、mysqld、sshd)不是这样跑的,它们是守护进程(daemon) :名字后面常带个 d,启动后终端立刻腾出来,关掉 SSH 它照样在后台跑。
这节课(lesson57)就把计算器改造成一个真正的系统服务,顺便补齐了客户端、做好了安装包。增量如下:
| 文件 | 变化 | 内容 |
|---|---|---|
Daemon.hpp |
新增(核心) | 手写守护进程化函数:fork、setsid、chdir、重定向 0/1/2 |
Main.cc |
改 | 启动时先调 Daemon(0,0),日志切到文件策略 |
Protocol.hpp |
改 | 新增客户端用的 BuildRequestString、GetResponse;服务端改成 while(Decode) 一次拆多条粘包;Response 加 ShowResult;Protocol 加默认构造 |
TcpClient.cc |
大改 | 从半成品变成完整可交互客户端:输入算式、发包、收包、打印结果 |
Common.hpp |
小改 | 退出码枚举新增 OPEN_ERR |
Makefile |
大改 | 目标改名 ServerNetCald/client_netcal,新增 output 打包目标 |
install.sh / unnstall.sh |
新增 | 安装/卸载脚本(拷贝到 /usr/bin) |
TcpServer.hpp |
小改 | accept 日志补上客户端地址 |
Socket.hpp、NetCal.hpp、Log.hpp、InetAddr.hpp、Mutex.hpp |
没变 | 与 lesson56 相同 |
这篇是收官篇,重点讲三件大事:守护进程化 、标准 IO 重定向 、部署打包,每一块我都给你可以直接复制的验证指令。
二、先建立四个概念:会话、进程组、控制终端、守护进程
要看懂 Daemon.hpp,得先补四个 Linux 进程模型的概念。别慌,用大白话讲。
2.1 进程组(process group)
一堆相关进程的集合,每个进程组有个组长(组长 PID = 进程组 ID,PGID)。你在 shell 里敲一条管道
cat file | grep xxx | wc -l,这三个进程就属于同一个进程组------shell 把它们编进一组,是为了能一起发信号(比如按 Ctrl+C,整组一起收到 SIGINT)。
2.2 会话(session)
多个进程组的集合。一次终端登录(一个 SSH 窗口)就是一个会话。会话里有:
一个会话首进程(session leader,通常就是登录上来的 shell);
一个前台进程组(占着终端、能收 Ctrl+C 的那组);
若干后台进程组。
2.3 控制终端(controlling terminal)
会话和一个终端绑定(SSH 里就是
/dev/pts/N)。终端产生的信号(Ctrl+C → SIGINT、Ctrl+\ → SIGQUIT、关终端 → SIGHUP)只发给这个会话里的进程。这就是"关 SSH 服务就死"的根:你的服务器进程是 shell 的后代,在 shell 的会话里、和终端绑着。终端一关,内核给会话首进程发 SIGHUP,一路传导,你的进程就被带走了。
2.4 守护进程的判定标准
一个"合格"的守护进程长这样(记住这张脸,等会儿用 ps 对着看):
父进程是 PID 1(被 init/systemd 收养,成了孤儿);
自己是会话首进程(SID = 自己的 PID),脱离了原来的登录会话;
没有控制终端(TTY 列显示
?);工作目录在
/(不占着别人的目录);0/1/2 三个标准描述符指向
/dev/null(不读键盘、不占屏幕);不受终端信号影响、长期在后台运行(再配一个 systemd/init 启动项,就能做到开机自启)。
三、Daemon.hpp 逐行精讲:守护进程是怎么造出来的
cpp
const std::string dev = "/dev/null";
void Daemon(int nochdir, int noclose)
{
// 1. 忽略IO,子进程退出等相关的信号
signal(SIGPIPE, SIG_IGN);
signal(SIGCHLD, SIG_IGN);
// 2. 父进程直接结束
if (fork() > 0)
exit(0);
// 3. 只能是子进程,孤儿了,父进程就是1
setsid();
if(nochdir == 0)
chdir("/");
// 4. 重定向标准输入、标准输出、标准错误到/dev/null
if (noclose == 0)
{
int fd = ::open(dev.c_str(), O_RDWR);
if (fd < 0)
{
LOG(LogLevel::FATAL) << "open " << dev << " errno";
exit(OPEN_ERR);
}
else
{
dup2(fd, 0);
dup2(fd, 1);
dup2(fd, 2);
close(fd);
}
}
}
3.1 第一步:忽略两个要命的信号
cpp
signal(SIGPIPE, SIG_IGN);
signal(SIGCHLD, SIG_IGN);
SIGPIPE(管道破裂信号) :TCP 连接有个经典场景------客户端已经把连接关了,服务端还往里 send。内核发现"对面没人收了",第一次 send 可能收到 RST,第二次 send 内核直接给本进程发 SIGPIPE ,这个信号的默认动作是终止进程。
想一下后果:一个客户端正常退出,服务器刚好多发了一次应答,整个服务器直接被信号打死,所有其他客户端全断。这怎么行?所以服务器必须 SIG_IGN(ignore)忽略它。忽略之后 send 不再杀进程,而是正常返回 -1,errno 置 EPIPE,程序自己判断错误码处理。
SIGCHLD(子进程退出信号) :咱们的 TcpServer 是双 fork 模型,会不断产生子进程/孙子进程。Linux 有个专门的约定:如果父进程把 SIGCHLD 的处置显式设成 SIG_IGN,那么它的子进程一退出就由内核自动回收,绝不产生僵尸进程,父进程连 wait 都不用调。
这里有个和 TcpServer 代码联动的细节:lesson56 的 TcpServer 父进程里还保留着 waitpid(id, nullptr, 0)。忽略 SIGCHLD 后这个 waitpid 会立刻返回 -1(errno 是 ECHILD,"我没有这个子进程要等",因为内核已经自动收走了),代码里 (void)rid; 本来就没看返回值,所以不影响。属于"双保险写重了",但没毛病。
注意:SIGCHLD 的这个自动回收语义是 Linux/System V 的约定,POSIX 标准没强制要求可移植代码这么写。课堂上 Linux 环境放心用。
3.2 第二步:fork 一次,父进程退出
cpp
if (fork() > 0)
exit(0);
父进程(在 shell 里启动我们的那个进程)立刻退出。子进程被 init(PID 1)收养,变成孤儿进程------这就是最终 PPID=1 的由来。
为什么非要 fork 一下,不能直接 setsid 吗? 这是 setsid 的硬性规定:
调用 setsid 的进程不能是进程组组长,否则调用失败(返回 -1,EPERM)。
而我们从 shell 启动时,shell 为了作业管理,往往让我们就是自己进程组的组长。fork 出的儿子 PID 是全新的,绝对不可能是任何已有组的组长,它来调 setsid 就一定成功。这一 fork 的本质目的就是"拿到一个非组长身份"。
3.3 第三步:setsid,彻底脱离终端
cpp
setsid();
这一行是整个守护进程化的灵魂。调用成功后,这个子进程:
成为一个新会话的首进程(SID = 自己的 PID);
成为一个新进程组的组长(PGID = 自己的 PID);
和原来的控制终端彻底脱钩,不再有控制终端。
脱钩之后,终端上的 Ctrl+C(SIGINT)、Ctrl+\(SIGQUIT)、关窗口(SIGHUP)全都找不到它了------这就是"关掉 SSH 服务器不死"的直接原因。
3.4 第四步:chdir("/")------为什么要把工作目录改到根
cpp
if(nochdir == 0)
chdir("/");
这是代码里打了问号 // 更改进程的工作路径???为什么?? 的地方,也是这篇必须讲透的点。
先说工作目录(cwd)是什么 :每个进程都记着一个"当前工作目录",你用相对路径(./log/my.log、conf/test.conf)打开文件时,就是相对这个目录解析的。我们在 /home/whb/code/lesson57 下启动程序,进程的 cwd 就是这个目录。
守护进程为什么必须从这个目录挪走?三个原因:
原因一:不能占着挂载点,导致文件系统无法卸载。
这是最经典的历史原因。守护进程是开机自启、长期运行的。假设系统启动时它的 cwd 在一个 U 盘、光盘、NFS 网络挂载目录里,这个目录就被进程"占用"了。管理员想卸载这个文件系统(
umount /mnt/usb),内核会报 device is busy ------有进程把这当工作目录,死活卸不掉。把 cwd 切到/(根分区永远不会被卸载),就不会阻塞任何文件系统的卸载/弹出。
原因二:启动目录是"借来的",不可靠。
你在自己的源码目录
~/code/lesson57里手工启动,cwd 恰好存在。但守护进程将来交给 systemd 在开机阶段启动时,cwd 可能是/或别的目录;如果依赖一个特定的开发目录才能跑,部署到别的机器上就崩。切到/是所有 Unix 系统都保证存在的目录。
原因三:给相对路径一个确定的、稳定的基准。
切完之后,进程无论从哪启动,相对路径的行为都一致,不再随启动者的位置漂移。
但是!这里有个大坑,我实测踩到了------chdir 之后,相对路径的日志文件直接写不出来了:
Main.cc 里 Daemon 之后启用了文件日志策略,而 Log.hpp 里日志路径是相对路径 ./log/my.log。守护进程已经 chdir 到 /,这个相对路径就被解析成 /log/my.log。普通用户在根目录 / 下没有创建目录的权限,于是:
create_directories("./log")抛filesystem_error,被代码 catch 掉;后面
SyncLog里ofstream out("/log/my.log", ios::app)打开失败,is_open()为假,函数直接 return(Log.hpp 第 80-83 行);结果:服务器在跑、日志却一条都没落地,而且不报错。 catch 块里那句
std::cerr << e.what()也同样写进了 /dev/null,屏幕上连个报错都没有。
补充一句更诡异的:如果你用 sudo ./ServerNetCald 8095 以 root 启动,root 有权限在 / 下建目录,它不会报错,而是真的把日志写到 /log/my.log 。所以这个相对路径无论权限够不够,行为都不符合预期------生产环境老老实实用绝对路径(如 /var/log/ServerNetCal/my.log)并提前建好目录、配好属主。
生产环境的标准做法是日志用绝对路径 ,比如 /var/log/servernetcal/my.log(配合合适的目录权限),或者让程序自己带一个配置项指定日志目录。课堂代码这里用相对路径,是为了教学简单,但这个"换了 cwd 相对路径就变脸"的连锁反应一定要懂。
验证指令(chdir 前后的对比):
cpp
# 普通前台程序的 cwd
sleep 100 &
ls -l /proc/$!/cwd # -> /home/ubuntu/code/lesson57(启动目录)
kill %1
# 守护进程的 cwd(等会启动后执行)
ls -l /proc/$(pgrep ServerNetCald)/cwd # -> /
3.5 第五步:把 0/1/2 重定向到 /dev/null
cpp
int fd = ::open(dev.c_str(), O_RDWR);
if (fd < 0) { ...exit(OPEN_ERR); }
else
{
dup2(fd, 0);
dup2(fd, 1);
dup2(fd, 2);
close(fd);
}
守护进程没有终端了,那标准输入(fd 0)、标准输出(fd 1)、标准错误(fd 2)这三个描述符怎么办?
先认识 /dev/null:
它是 Linux 里的"黑洞设备文件":
往里写任何东西,都成功返回,但数据直接丢弃(像碎纸机);
从它读,立刻得到 EOF(返回 0),不阻塞;
永远不会满、永远不报错。
再看 open 的小技巧:进程里文件描述符是按最小空闲原则分配的。此时 0/1/2 还占着(虽然指向的终端没了),open 返回的是 3。
dup2 是关键操作 :dup2(fd, 0) 的意思是"让描述符 0 也指向 fd 所指的文件表项"(先把 0 原来指向的关掉,再重定向)。连续三次,让 0、1、2 全都指向 /dev/null 对应的那个文件表项。然后 close(fd) 关掉多余的 3------注意关闭 3 不影响 0/1/2,因为 dup2 已经复制了引用。
为什么不直接 close(0)、close(1)、close(2)? 代码注释也说了"不推荐",原因有两个:
fd 编号被库函数当约定写死了 。C/C++ 标准库的
cin/cout/cerr、C 的printf/perror,底层永远往 1 和 2 上写。你把 1、2 关了,它们变成空闲号,之后程序第一次 open 文件(比如打开日志、打开 socket 之前 open 配置)可能恰好分到 1 或 2------这时 printf 输出的东西就写进了那个文件/管道,灵异事故;裸 close 后写操作直接 EBADF 报错。重定向到 /dev/null 后,写操作"正常成功"(数据进黑洞)、读操作"正常 EOF",程序里所有默认走 0/1/2 的逻辑都不用改,最省心。
3.6 参数 nochdir、noclose 的含义:和系统 daemon(3) 对齐
Daemon(int nochdir, int noclose) 的参数名和取值逻辑是照着 glibc 的库函数 daemon(3) 抄的,Main.cc 里那行注释 // daemon(1, 1); 就是指系统自带的版本:
| 参数 | 传 0(默认做法) | 传 1 |
|---|---|---|
nochdir |
要 chdir 到 / |
不切换工作目录(no chdir) |
noclose |
要重定向 0/1/2 到 /dev/null | 不动标准描述符(no close) |
Main.cc 调的是 Daemon(0, 0)------两个动作都做,这是最彻底、最标准的守护化。传 (1,1) 适合调试:保留工作目录和终端输出,方便看 printf 打日志。
想看系统原版可以执行 man 3 daemon,参数语义一模一样。我们手写一遍是为了学原理,工程上直接调 daemon(0,0) 也行。
3.7 一个代码里没做、但标准流程有的动作:umask
完整的守护进程七步法里还有一句 umask(0),这份代码没写。作用是把文件权限掩码清零,保证守护进程以后 open/创建文件时,传入的 mode 参数不被继承自 shell 的 umask 干扰(不同用户 shell 的 umask 可能是 022、077,创建日志/配置文件的权限就会不一致)。知道即可,不算 bug,是教学裁剪。
四、Main.cc:守护化的时机与日志切换
cpp
int main(int argc, char *argv[])
{
if (argc != 2) { Usage(argv[0]); exit(USAGE_ERR); }
std::cout << "服务器已经启动,已经是一个守护进程了" << std::endl;
Daemon(0, 0);
// daemon(1, 1);
// Enable_Console_Log_Strategy();
Enable_File_Log_Strategy();
// 1. 业务层 2. 协议层 3. 服务器层(和 lesson56 相同)
...
tsvr->Start();
return 0;
}
三个时机细节:
那句
cout必须在 Daemon 之前。Daemon 一执行,fd 1 就被重定向到 /dev/null 了,之后再 cout 用户在终端上什么都看不到。所以"服务器已经启动"这句给用户的提示,要赶在重定向前打印。实测也能看到:敲完启动命令,提示语出现在终端,然后 shell 提示符立刻回来了;Daemon 要在所有业务初始化之前。chdir 和重定向会影响后面所有文件路径和标准 IO,越早做越干净;
日志策略从"打屏"切成"写文件"。守护进程没有终端,继续往屏幕打日志就是往 /dev/null 打(等于扔了),所以这里注释掉 Console 策略、启用 File 策略。配合前面说的坑:这个相对路径日志在 chdir("/") 后普通用户写不出来,要落地得换绝对路径。
不只是 LOG,代码里所有 cout 都会被重定向"没收" 。比如 Protocol.hpp 的 GetRequest 里那三行调试用的
std::cout << buffer_queue,守护化之后写的是 fd 1,同样进了 /dev/null------你在终端上再也看不到它们。守护进程想输出任何信息,唯一的归宿就是文件(或 syslog),这也是为什么必须有文件日志策略。
启动后的直观感受(实测):
cpp
$ ./ServerNetCald 8095
服务器已经启动,已经是一个守护进程了
$ # 提示符立刻回来,终端没被占住,echo $? 是 0
五、守护进程验证指令合集(重点,逐条执行)
先手动编译(Makefile 的坑第八节讲,先绕过去):
cd ~/code/lesson57
g++ -o ServerNetCald Main.cc -std=c++17 -ljsoncpp -lpthread
./ServerNetCald 8095
5.1 看进程身份:PPID / PGID / SID / 终端
cpp
ps -eo pid,ppid,pgid,sid,tty,stat,cmd | grep ServerNetCald | grep -v grep
实测输出:
cpp
PID PPID PGID SID TT STAT CMD
3078942 1 3078942 3078942 ? Ss ./ServerNetCald 8095
对着第二节的标准逐项验收:
PPID=1:爹是 init,孤儿被收养 ✔
PGID=SID=PID:自己是新进程组组长、新会话首进程,setsid 成功 ✔
TT=?:没有控制终端,脱离了 SSH 会话 ✔
STAT=Ss:S可中断睡眠(在等连接),s表示会话首进程 ✔
对比一下普通前台进程(另开终端跑 sleep 1000,再 ps -eo pid,ppid,pgid,sid,tty,stat,cmd | grep sleep):它的 TTY 是 /dev/pts/N,SID 等于你 shell 的 SID------差别一目了然。
另外,课堂上老师用的是 ps ajx(你截图里那条),它的列顺序是 PPID PID PGID SID TTY TPGID STAT UID TIME COMMAND,和上面的输出是同一批信息、排列不同:
cpp
ps ajx | grep ServerNetCald | grep -v grep
实测输出(对应你截图的格式):
cpp
PPID PID PGID SID TTY TPGID STAT UID TIME COMMAND
1 3105626 3105626 3105626 ? -1 Ss 1000 0:00 ./ServerNetCald 8097
读法(这一行是我真机实跑 ps ajx 抓的):PPID=1(爹是 init)、PID=PGID=SID(自己当组长兼会话首进程)、TTY=?(无控制终端)、TPGID=-1 是"没有前台进程组、没有控制终端"的铁证 (普通前台进程这里显示终端前台组的 PGID,是个正数)、STAT 里的 s 同样表示会话首进程。
5.2 看工作目录是不是 /
cpp
ls -l /proc/$(pgrep ServerNetCald)/cwd
# lrwxrwxrwx ... /proc/3078942/cwd -> /
/proc/[pid]/cwd 是内核维护的符号链接,实时指向进程的当前工作目录。守护进程应当指向 /。这就是 chdir 的直接证据。
5.3 看 0/1/2 是不是 /dev/null
cpp
ls -l /proc/$(pgrep ServerNetCald)/fd/0 \
/proc/$(pgrep ServerNetCald)/fd/1 \
/proc/$(pgrep ServerNetCald)/fd/2
实测三条全部是:
cpp
0 -> /dev/null
1 -> /dev/null
2 -> /dev/null
对比守护化之前 的进程,这三个是 /dev/pts/2(你的终端)。这也是你给的截图里那组对比指令做的事。
5.4 验证脱离终端:关 SSH 不死
cpp
# 1. 启动守护进程
./ServerNetCald 8095
# 2. 直接关闭当前 SSH 窗口/标签页
# 3. 重新登录,执行:
ps -eo pid,ppid,tty,cmd | grep ServerNetCald | grep -v grep
ss -ltnp | grep 8095
进程还在、端口还在听。换成前台程序,这一步进程已经随 SIGHUP 没了。
顺便:如果想让一个没做守护化 的普通程序也忽略 SIGHUP 在后台跑,可以用 shell 的
nohup ./xxx &。但 nohup 只解决 SIGHUP 一件事,setsid、chdir、重定向都不做,和正规守护进程不是一回事,可以对比体会。
5.5 验证没有僵尸进程
cpp
# 连续建 3 个连接再断开(要求装了 python3)
for i in 1 2 3; do
python3 -c "
import socket,time
s=socket.socket(); s.connect(('127.0.0.1',8095))
s.sendall(b'26\r\n{\"x\":10,\"y\":20,\"oper\":43}\n\r\n')
time.sleep(0.3); s.close()"
done
sleep 0.5
ps -eo pid,ppid,stat,cmd | grep -E 'ServerNetCald|defunct' | grep -v grep
实测只剩主进程一行(STAT=Ss,PPID=1),没有任何 Z/defunct 。这就是 signal(SIGCHLD, SIG_IGN) 让内核自动回收的效果。
想故意看看僵尸长什么样,可以写个最小对照:父进程 fork 后 sleep 不收尸,子进程退出,ps 里就会出现 <defunct> 且 STAT 是 Z。
5.6 验证 SIGPIPE 被忽略(含默认动作的真机演示)
先说句实话:咱们这份服务器代码里,子进程是先 Recv,对端正常关闭时 recv 返回 0 就直接退出循环了,根本不会再 send ,所以普通的"连一下就断"很难真的把 SIGPIPE 打出来。Daemon 里忽略它属于服务器的标准防御动作------以后协议复杂了(主动推送、连写多个包),窗口就出现了。验证分两步,都是我在你这台机器上真跑过的。
第一步,验证 SIGPIPE 的默认动作确实是"杀进程":
cpp
kill -l PIPE # 输出 13,SIGPIPE 的信号编号
bash -c 'kill -PIPE $$' # 子 shell 给自己发 SIGPIPE(不忽略、不捕获)
echo $? # 输出 141 = 128 + 13,128 表示被信号杀死,13 是 SIGPIPE
实测输出 141。这就证明了:不忽略它,一个写操作就能让进程无任何征兆地消失。想看权威定义可以 man 7 signal,Signal 表里 SIGPIPE 的 Action 列是 Term(终止)。
第二步,验证咱们的服务器确实活下来了。 下面这个脚本故意"请求刚发出去就用 RST 立刻断开",让服务端的 send 撞上已关闭的对端(利用 SO_LINGER 发 RST 而不是温柔的 FIN),重复 50 次:
cpp
for i in $(seq 1 50); do
python3 -c "
import socket,struct
s=socket.socket(); s.connect(('127.0.0.1',8095))
s.sendall(b'26\r\n{\"oper\":43,\"x\":10,\"y\":20}\n\r\n')
s.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack('ii',1,0))
s.close()"
done
ss -ltnp | grep 8095 # 守护进程依旧在监听,没有被打挂
ps -o pid,stat -p $(pgrep ServerNetCald)
哪怕个别请求正好撞上写时机,服务器也照常提供服务。把 Daemon.hpp 里那行 signal(SIGPIPE, SIG_IGN) 注释掉重新编译再跑这个循环,才有机会看到主进程被信号带走------这一对照,忽略信号的意义就懂了。
5.7 收尾:停掉守护进程
守护进程不受 Ctrl+C 管(也没占终端),停止要用 PID 发信号:
cpp
kill $(pgrep ServerNetCald) # 发 SIGTERM,优雅退出
# 耍赖时:kill -9 $(pgrep ServerNetCald)
六、Protocol.hpp:客户端能力补齐 + 粘包处理升级
守护进程是这节课的主角,但协议层也有三处实质改动,客户端能跑全靠它们。
6.1 Protocol 加了默认构造:客户端不需要业务回调
cpp
Protocol() {} // 新增:客户端用
Protocol(func_t func) : _func(func) {} // 服务端用
lesson56 的 Protocol 只有带 func_t 的构造,因为只有服务端要"拿到 Request 算 Response"。客户端不需要任何计算业务,只需要 Encode/Decode/序列化这些工具函数,所以加个无参构造让 make_unique<Protocol>() 能成立。
6.2 BuildRequestString:客户端侧的"打包"
cpp
std::string BuildRequestString(int x, int y, char oper)
{
Request req(x, y, oper); // 1. 造请求对象
std::string json_req = req.Serialize(); // 2. 序列化成 JSON
return Encode(json_req); // 3. 加长度报头,返回完整报文
}
客户端不用关心报文格式细节,一行拿到 26\r\n{"oper":43,"x":10,"y":20}\n\r\n 这种能直接 send 的字符串。
6.3 GetResponse:客户端收应答,同样按长度拆包
cpp
bool GetResponse(std::shared_ptr<Socket> &client, std::string &resp_buff, Response *resp)
{
while (true)
{
int n = client->Recv(&resp_buff);
if (n > 0)
{
std::string json_package;
while (Decode(resp_buff, &json_package)) // 同样循环拆
{
resp->Deserialize(json_package);
}
return true;
}
else if (n == 0) { std::cout << "server quit " << std::endl; return false; }
else { std::cout << "recv error" << std::endl; return false; }
}
}
和服务端 GetRequest 是镜像关系:resp_buff 由调用方(TcpClient 的 main)持有、跨轮次累积;recv 到数据就 while(Decode) 把里面完整的应答一条条拆出来反序列化。返回 false 让客户端主循环退出。
6.4 关键升级:if(Decode) 改成 while(Decode)
这是 lesson56 → lesson57 服务端最容易被忽略但很重要的一处改动。
lesson56:
cpp
bool ret = Decode(buffer_queue, &json_package);
if(!ret) continue;
// 只处理一条
lesson57:
cpp
while (Decode(buffer_queue, &json_package))
{
// 处理一条;buffer 里若还有完整报文,下一轮继续拆
}
为什么必须改成循环?TCP 是字节流,一次 recv 完全可能读到两条以上的请求 (客户端发得快、网络把包合并了)。用 if 的话,一次 recv 里粘了两条请求只处理第一条,第二条烂在 buffer 里,要等下一条请求来"捎带"才被处理------表现为计算器响应延迟、最后一条请求没人理。while(Decode) 保证一次 recv 读到多少条就当场处理多少条,buffer 清空到不含完整报文为止。
6.5 Response::ShowResult:结果展示
cpp
void ShowResult()
{
std::cout << "计算结果是: " << _result << "[" << _code << "]" << std::endl;
}
输出形如 计算结果是: 30[0],方括号里是状态码,0 成功、1 除零、2 模零、3 非法运算符,和服务端 Cal 的约定一一对应。
七、TcpClient.cc:完整版客户端逐行看
lesson56 的 TcpClient 连上就结束,这节课补成了完整的交互式客户端:
cpp
void GetDataFromStdin(int *x, int *y, char *oper)
{
std::cout << "Please Enter x: ";
std::cin >> *x;
std::cout << "Please Enter y: ";
std::cin >> *y;
std::cout << "Please Enter oper: ";
std::cin >> *oper;
}
int main(int argc, char *argv[])
{
if (argc != 3) { Usage(argv[0]); exit(USAGE_ERR); }
std::string server_ip = argv[1];
uint16_t server_port = std::stoi(argv[2]);
std::shared_ptr<Socket> client = std::make_shared<TcpSocket>();
client->BuildTcpClientSocketMethod();
if (client->Connect(server_ip, server_port) != 0)
{
std::cerr << "connect error" << std::endl;
exit(CONNECT_ERR);
}
std::unique_ptr<Protocol> protocol = std::make_unique<Protocol>();
std::string resp_buffer;
while (true)
{
int x, y; char oper;
GetDataFromStdin(&x, &y, &oper); // 1. 键盘输入
std::string req_str = protocol->BuildRequestString(x, y, oper); // 2. 打包
client->Send(req_str); // 3. 发送
Response resp;
bool res = protocol->GetResponse(client, resp_buffer, &resp); // 4. 收应答
if(res == false) break;
resp.ShowResult(); // 5. 展示
}
client->Close();
return 0;
}
客户端五步和服务端六步流水线正好咬合:输入 → 构造+序列化+Encode → send → recv+Decode+反序列化 → 打印。resp_buffer 在 while 外定义,和服务端的 buffer_queue 同理,跨多次接收攒数据。
连接失败这节课补上了处理(lesson56 的 if 成功块是空的):返回非 0 就报错并以 CONNECT_ERR 退出。
实测一把(守护进程不占终端,另开一个窗口跑客户端即可):
cpp
# 终端1:起守护服务
./ServerNetCald 8095
# 终端2:起客户端,按提示依次输入 10、20、+
g++ -o client_netcal TcpClient.cc -std=c++17 -ljsoncpp -lpthread
./client_netcal 127.0.0.1 8095
# Please Enter x: 10
# Please Enter y: 20
# Please Enter oper: +
# 计算结果是: 30[0]
# 继续输入 10、0、/ -> 计算结果是: 0[1](除零)
# 输入 10、5、^ -> 计算结果是: 0[3](非法运算符)
这里有个真实的代码缺陷要提醒 :GetDataFromStdin 没检查 std::cin 的状态。当你按 Ctrl+D(输入 EOF),或者用管道喂数据喂完后,cin >> *x 读取失败,变量保持旧值/随机值,但函数照返回、主循环照发请求,于是陷入死循环疯狂发包刷屏(我用管道实测刷出了几兆输出)。健壮的写法是:
cpp
if (!(std::cin >> *x >> *y >> *oper)) break; // EOF/错误输入时退出循环
写自己的客户端时记得补上。
八、Makefile 与部署打包:从源码到 /usr/bin
8.1 目标命名:服务名后面的 d
cpp
all:ServerNetCald client_netcal
ServerNetCald:main.cc
g++ -o $@ $^ -std=c++17 -ljsoncpp -static -lpthread
client_netcal:TcpClient.cc
g++ -o $@ $^ -std=c++17 -ljsoncpp -static -lpthread
服务端程序叫 ServerNetCald,末尾的 d 就是 daemon 的缩写 ,是 Unix 世界给守护进程命名的惯例:sshd、httpd、systemd、crond 都是这样。客户端叫 client_netcal,普通程序不带 d。
8.2 这份 Makefile 实测有四个坑
我在你的环境里逐条跑过,全中:
坑 1:main.cc 大小写错误(lesson56 就有,没修)。
Makefile 里是 ServerNetCald:main.cc(小写 m),实际文件是 Main.cc(大写 M),Linux 大小写敏感,直接 make 报:
cpp
make: *** No rule to make target 'main.cc', needed by 'ServerNetCald'. Stop.
坑 2:-static 在你这台机器上编不过。
-static 要求所有库都有静态版本(.a),但系统只装了 jsoncpp 的动态库,实测:
cpp
/usr/bin/ld: cannot find -ljsoncpp: No such file or directory
去掉 -static 就能编过(-lpthread 保留无妨)。想用纯静态,得先有 libjsoncpp.a(源码编译 jsoncpp 时加 -DBUILD_STATIC_LIBS=ON)。课堂阶段直接动态链接即可。
正确的手动编译指令:
cpp
g++ -o ServerNetCald Main.cc -std=c++17 -ljsoncpp -lpthread
g++ -o client_netcal TcpClient.cc -std=c++17 -ljsoncpp -lpthread
坑 3:test.conf 文件缺失。
打包目标里有 @cp test.conf output/conf,但当前目录没有 test.conf(你截图里有,应该是漏拷了),make output 实测报:
cpp
cp: cannot stat 'test.conf': No such file or directory
touch test.conf 建一个空文件即可继续。
坑 4:@mkdir output 没加 -p,且脚本文件名拼错。
第一次失败会残留半成品
output/目录,再跑make output,mkdir output(不带 -p)报File exists直接失败。应写mkdir -p output;Makefile 里
@cp uninstall.sh output/,但实际脚本文件名是unnstall.sh(少了个 i) ,对不上,cp 失败。把文件改名成uninstall.sh或把 Makefile 改成都一致才行。
8.3 output 目标:标准的发布包结构
把四个坑补平后,make output 干的事值得学:
cpp
output:
@mkdir output
@mkdir -p output/bin
@mkdir -p output/conf
@mkdir -p output/log
@cp ServerNetCald output/bin
@cp client_netcal output/bin
@cp test.conf output/conf
@cp install.sh output/
@cp uninstall.sh output/
@tar czf output.tgz output
它生成一个符合 Linux 惯例的发布目录并打成 tar 包:
cpp
output/
├── bin/
│ ├── ServerNetCald # 可执行程序(服务端)
│ └── client_netcal # 可执行程序(客户端)
├── conf/
│ └── test.conf # 配置文件
├── log/ # 日志目录(预留)
├── install.sh # 安装脚本
└── uninstall.sh # 卸载脚本
bin/conf/log 三分法是行业惯例:可执行文件、配置文件、日志文件分开。你看 nginx(sbin/conf/logs)、MySQL(bin/conf/data+log)都是这个思路。好处是升级时只换 bin,配置和日志不动;还能分别给权限。
tar czf output.tgz 中 c 创建、z gzip 压缩、f 指定文件名,打出一个随处分发的压缩包。验证:
tar tzf output.tgz # 不释放,只列出包里的内容
8.4 install.sh / uninstall.sh:装进 PATH
cpp
#!/usr/bin/bash
# install.sh
cp -f ./bin/ServerNetCald /usr/bin
cp -f ./bin/client_netcal /usr/bin
cpp
#!/usr/bin/bash
# uninstall.sh(注意实际文件名被拼成了 unnstall.sh)
rm -rf /usr/bin/ServerNetCald
rm -f /usr/bin/client_netcal
/usr/bin 是系统 PATH 里的标准可执行目录。拷进去以后,在任何目录下直接敲程序名就能运行,不用写 ./ 或全路径------这就是你截图里 which ServerNetCald 能显示 /usr/bin/ServerNetCald 的原因。
验证安装脚本(要在补平坑、make output 成功之后):
cpp
cd output
sudo ./install.sh
which ServerNetCald # -> /usr/bin/ServerNetCald
which client_netcal # -> /usr/bin/client_netcal
ServerNetCald 8095 # 任意目录都能起
sudo ./uninstall.sh # 卸载
which ServerNetCald # 无输出
第一行 #!/usr/bin/bash 叫 shebang,告诉系统用哪个解释器执行脚本。脚本要加可执行权限 chmod +x install.sh 才能 ./install.sh 这么跑(你截图里它们本来就是绿色可执行的)。
小知识:严格按 Linux 规范,第三方手工安装的软件更推荐放
/usr/local/bin(发行版包管理器管/usr/bin,避免相互覆盖)。课堂演示用 /usr/bin 没问题。
8.5 Common.hpp 新增的退出码
cpp
enum ExitCode { ..., FORK_ERR, OPEN_ERR };
新增的 OPEN_ERR 正是给 Daemon.hpp 里 open("/dev/null") 失败 准备的退出码------虽然 /dev/null 几乎不可能打开失败,但"出错有明确退出码"这个习惯要保持。
九、完整验证流程一条龙
把分散的指令串成一遍完整的部署验证(在你自己机器上,把坑补平后):
cpp
# 0. 编译(绕开 Makefile 的大小写和 -static 坑)
cd ~/code/lesson57
g++ -o ServerNetCald Main.cc -std=c++17 -ljsoncpp -lpthread
g++ -o client_netcal TcpClient.cc -std=c++17 -ljsoncpp -lpthread
# 1. 启动守护进程,观察终端立刻返回
./ServerNetCald 8095
PID=$(pgrep ServerNetCald); echo "守护进程 PID=$PID"
# 2. 五件套验收
ps -o pid,ppid,pgid,sid,tty,stat,cmd -p $PID # PPID=1,SID=PID,TTY=?,STAT=Ss
ls -l /proc/$PID/cwd # -> /
ls -l /proc/$PID/fd/0 /proc/$PID/fd/1 /proc/$PID/fd/2 # 全是 /dev/null
ss -ltnp | grep 8095 # 端口在监听
# 3. 客户端实测
./client_netcal 127.0.0.1 8095
# 10 20 + -> 30[0]
# 10 0 / -> 0[1]
# 10 5 ^ -> 0[3]
# 4. 脱离终端验证:另开窗口 kill 掉终端模拟器/断开 SSH,再登录看
ps -eo pid,ppid,tty,cmd | grep ServerNetCald | grep -v grep
# 5. 停止
kill $PID
十、三篇收官:这个网络计算器项目的完整技术地图
| 篇目 | 主题 | 核心产出 |
|---|---|---|
| 上篇 lesson55 | 骨架与工具 | 模板方法封 Socket、策略模式日志、双 fork 并发、jsoncpp 入门 |
| 中篇 lesson56 | 协议落地 | JSON 序列化/反序列化、len\r\n{json}\r\n 报文分隔、Encode/Decode 解粘包、Cal 业务三层解耦 |
| 终篇 lesson57 | 服务化 | 守护进程七步(信号/fork/setsid/chdir/重定向)、完整客户端、bin/conf/log 打包与安装 |
到这里,一个"能开机自启、断网不掉、带安装包的后台网络服务"该有的零件咱们全手写了一遍。最后把终篇最该带走的东西压成几句话:
守护进程 = 换身份 + 换会话 + 换目录 + 换 stdio:fork 让爹死(拿非组长身份)、setsid 脱终端(SID=PID、TTY=?)、chdir("/") 不占挂载点、dup2 把 0/1/2 送进 /dev/null;
为什么 chdir 到根 :防止占着可卸载的文件系统导致 umount 忙、不依赖启动目录、给相对路径稳定基准;副作用是相对路径日志会写到
/log,普通用户没权限导致日志静默丢失,生产要用绝对路径;为什么重定向而不是 close:0/1/2 编号被标准库写死,裸 close 后新文件可能占用这些号造成输出错乱;/dev/null 让写成功丢弃、读立即 EOF,业务代码零改动;
两个信号要忽略:SIGPIPE(客户端已关还写,不忽略会杀整个服务器)、SIGCHLD(Linux 下忽略即自动回收子进程,无僵尸);
协议细节 :一次 recv 可能多条报文,
if(Decode)必须升级成while(Decode);客户端要检查 cin 的 EOF,否则死循环;工程收尾 :服务名带 d 是惯例;bin/conf/log 分目录打包;装 /usr/bin 后用
which验收;-static需要.a静态库;Makefile 里main.cc大小写、mkdir -p、uninstall.sh拼写这些坑都要自己能排。
十一.完整代码如下:














