【Linux网络加餐课】(篇八)网络版计算器(终篇):守护进程、标准 IO 重定向与部署打包

一、这节课干了什么:从"前台程序"到"系统服务"

前两篇咱们把网络计算器写通了: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 新增客户端用的 BuildRequestStringGetResponse;服务端改成 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.hppNetCal.hppLog.hppInetAddr.hppMutex.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 对着看):

  1. 父进程是 PID 1(被 init/systemd 收养,成了孤儿);

  2. 自己是会话首进程(SID = 自己的 PID),脱离了原来的登录会话

  3. 没有控制终端(TTY 列显示 ?);

  4. 工作目录在 /(不占着别人的目录);

  5. 0/1/2 三个标准描述符指向 /dev/null(不读键盘、不占屏幕);

  6. 不受终端信号影响、长期在后台运行(再配一个 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();

这一行是整个守护进程化的灵魂。调用成功后,这个子进程:

  1. 成为一个新会话的首进程(SID = 自己的 PID);

  2. 成为一个新进程组的组长(PGID = 自己的 PID);

  3. 和原来的控制终端彻底脱钩,不再有控制终端。

脱钩之后,终端上的 Ctrl+C(SIGINT)、Ctrl+\(SIGQUIT)、关窗口(SIGHUP)全都找不到它了------这就是"关掉 SSH 服务器不死"的直接原因。


3.4 第四步:chdir("/")------为什么要把工作目录改到根

cpp 复制代码
if(nochdir == 0)
    chdir("/");

这是代码里打了问号 // 更改进程的工作路径???为什么?? 的地方,也是这篇必须讲透的点。

先说工作目录(cwd)是什么 :每个进程都记着一个"当前工作目录",你用相对路径(./log/my.logconf/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 掉;

  • 后面 SyncLogofstream 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)? 代码注释也说了"不推荐",原因有两个:

  1. fd 编号被库函数当约定写死了 。C/C++ 标准库的 cin/cout/cerr、C 的 printf/perror,底层永远往 1 和 2 上写。你把 1、2 关了,它们变成空闲号,之后程序第一次 open 文件(比如打开日志、打开 socket 之前 open 配置)可能恰好分到 1 或 2------这时 printf 输出的东西就写进了那个文件/管道,灵异事故;

  2. 裸 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;
}

三个时机细节:

  1. 那句 cout 必须在 Daemon 之前。Daemon 一执行,fd 1 就被重定向到 /dev/null 了,之后再 cout 用户在终端上什么都看不到。所以"服务器已经启动"这句给用户的提示,要赶在重定向前打印。实测也能看到:敲完启动命令,提示语出现在终端,然后 shell 提示符立刻回来了;

  2. Daemon 要在所有业务初始化之前。chdir 和重定向会影响后面所有文件路径和标准 IO,越早做越干净;

  3. 日志策略从"打屏"切成"写文件"。守护进程没有终端,继续往屏幕打日志就是往 /dev/null 打(等于扔了),所以这里注释掉 Console 策略、启用 File 策略。配合前面说的坑:这个相对路径日志在 chdir("/") 后普通用户写不出来,要落地得换绝对路径。

  4. 不只是 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=SsS 可中断睡眠(在等连接),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 世界给守护进程命名的惯例:sshdhttpdsystemdcrond 都是这样。客户端叫 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 outputmkdir 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.tgzc 创建、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 打包与安装

到这里,一个"能开机自启、断网不掉、带安装包的后台网络服务"该有的零件咱们全手写了一遍。最后把终篇最该带走的东西压成几句话:

  1. 守护进程 = 换身份 + 换会话 + 换目录 + 换 stdio:fork 让爹死(拿非组长身份)、setsid 脱终端(SID=PID、TTY=?)、chdir("/") 不占挂载点、dup2 把 0/1/2 送进 /dev/null;

  2. 为什么 chdir 到根 :防止占着可卸载的文件系统导致 umount 忙、不依赖启动目录、给相对路径稳定基准;副作用是相对路径日志会写到 /log,普通用户没权限导致日志静默丢失,生产要用绝对路径

  3. 为什么重定向而不是 close:0/1/2 编号被标准库写死,裸 close 后新文件可能占用这些号造成输出错乱;/dev/null 让写成功丢弃、读立即 EOF,业务代码零改动;

  4. 两个信号要忽略:SIGPIPE(客户端已关还写,不忽略会杀整个服务器)、SIGCHLD(Linux 下忽略即自动回收子进程,无僵尸);

  5. 协议细节 :一次 recv 可能多条报文,if(Decode) 必须升级成 while(Decode);客户端要检查 cin 的 EOF,否则死循环;

  6. 工程收尾 :服务名带 d 是惯例;bin/conf/log 分目录打包;装 /usr/bin 后用 which 验收;-static 需要 .a 静态库;Makefile 里 main.cc 大小写、mkdir -puninstall.sh 拼写这些坑都要自己能排。


十一.完整代码如下:

相关推荐
Escalating_xu1 小时前
【Linux 多线程】自旋锁:从 atomic_flag 原子操作到 pthread_spin_* 与高竞争优化
linux·系统架构
kkkkkkkkkk_Z1 小时前
学嵌入式和Linux应用编程|学习日记:深入理解TCP协议与网络编程实战
linux·笔记·学习
海宇服务2 小时前
零信任架构实战:基于海宇公安二要素认证即时版构建自动化号码发卡网关
运维·人工智能·架构·自动化
蓝速科技2 小时前
数字人一体机芯片选型:RK3576 与 X86 场景化对比指南丨蓝速科技
大数据·运维·数据库·人工智能·科技
kaoa0002 小时前
Linux入门攻坚——90、Hadoop-2-MapReduce计算框架及Hadoop生态系统概览
linux·hadoop·mapreduce
The Chosen One9852 小时前
OS第二章随手记(2.1)
linux·运维·服务器·笔记
小马同学-2 小时前
04 容器存储知识点
运维·docker·云计算
暖核3 小时前
多容器部署利器,详解 Docker Compose 核心配置与命令
运维
青峰客3 小时前
DeepSeek 大模型新手快速上手指南
运维·服务器·github