《Linux 高性能服务器编程》 实践
¶🙋♂ 说明
主要针对 《Linux 高性能服务器编程》 中一些重要的例子进行实践。
📘 实践内容如下:
🍊 IO 复用 :
1️⃣ select :处理带外数据 (P148)
2️⃣ poll :聊天室程序 (P165)
3️⃣ epoll :同时处理 TCP 和 UDP 服务 (P171)
4️⃣ LT 和 ET 模式 :LT 和 ET 模式(P154)
5️⃣ epoll + ET + EPOLLONESHOT :使用 EPOLLONESHOT 事件 (P157)
🍋 有限状态机 :HTTP 请求读取和分析 (P137)
🍎 统一事件源 : 统一事件源 (P184)
🍍 定时器 :
1️⃣ 基于升序链表的定时器 (P196)
2️⃣ 处理非活动连接 (P200)
🍑 共享内存 :聊天室服务器程序( 进阶 :使用共享内存)(P255)
🍉 进程池与线程池 :
1️⃣ 半同步/半异步进程池 (P289)
2️⃣ 用进程池实现简单的 CGI 服务器 (P298)
3️⃣ 半同步/半反应堆线程池 (P301)
🍇 web 服务器 :
1️⃣ http_conn 类 (P304)
2️⃣ main 函数 (P318)
3️⃣ 压力测试 (P329)
参考:《Linux 高性能服务器编程》
代码见仓库🏡 : Linux_Server_Programming_emamples
¶1 IO 复用
¶1.1 同时接收普通数据和带外数据
服务器代码使用 select 监听可读事件,以及异常事件,接收到客户端发送的数据后输出。
🔹 服务器接收数据:
1 | // 监听读事件 异常事件 |
🔸 客户端发送数据:
1 | const char* oob_data = "abc"; |
⭐️ 运行结果:
发现 👀 :客户端发送给服务器的 3 字节的带外数据 “abc” 中仅有最后一个字符 “c” 被服务器当成了真正的带外数据接收。
补充 🙋
在 Linux 环境下,内核通知应用程序带外数据到达主要有两种方法:
🔹 IO 复用技术,select 等系统调用在接收到带外数据时返回,并向应用程序报告 socket 上的异常事件,如1.1的例子。
🔸 使用 SIGURG 信号,见代码清单 10-3 : 用 SIGURG 检测带外数据是否到达。
服务器代码:1_1_server.cpp
客户端代码:1_1_client.cpp
详细代码见仓库 🏡 : Linux_Server_Programming_emamples
¶1.2 聊天室程序
该聊天室能够让所有用户同时在线群聊,它分为客户端和服务器两个部分,利用 poll 实现。
客户端 程序有 两个 功能:
🔹 一是从标准输入终端读入用户数据,并将用户数据发送至服务器;
1 | // 利用 splice 函数将用户输入内容直接定向到网络连接上以发送之 |
🔸 二是往标准输出终端打印服务器发送给它的数据。
1 | else if (fds[1].revents & POLLIN) { |
服务器 的功能时接收客户数据,并把客户数据发送给每一个登录到该服务器上的客户(发送数据者除外)。
🔹 客户数据结构体,以及非阻塞设计:
1 | // 客户数据: |
🔸 创建用户数组,poll 检测用户状态
1 | // 创建 users 数组,分配 FD_LIMIT 个 client_data 对象 |
🔹 处理各种情况:
连接请求、错误信息、客户端关闭连接、处理用户输入数据和发送用户数据。
1 | // 处理连接请求, 内部需要考虑用户数量 |
⭐️ 运行结果:
连接 5 个用户,超出连接数量,会导致服务器拒绝服务。
用户( fd = 4、5、8
)分别发送消息
除了发送者,所有用户都会接收到信息,用户( fd = 6
)终端结果如下:
服务器代码: 1_2_chat_server.cpp
客户端代码: 1_2_chat_client.cpp
详细代码见仓库 🏡 : Linux_Server_Programming_emamples
¶1.3 同时处理 TCP 和 UDP 服务
从 bind 系统调用的参数来看,一个 socket 只能与一个 socket 地址绑定,即一个 socket 只能用来监听一个端口。因此,服务器如果要同时监听多个端口,就必须创建多个 socket,并将它们分别绑定到各个端口上。这样一来,服务器程序就需要同时管理多个监听 socket,IO 复用技术就有了用武之地。另外,即使是同一个端口,如果服务器同时处理该端口上 TCP 和 UDP 请求,则也需要创建不同的 socket:一个流 socket,另一个是数据报 socket,并将它们都绑定到该端口上。
服务器 处理 TCP连接请求,接收 TCP 数据、接收 UDP 数据,并对它们做回声处理(回射服务器);
🔹 处理 TCP UDP 请求
1 | // 注册事件 |
注意 🙋 :此时 UDP 与 TCP 连接可以绑定相同的 address ,同一个端口的 TCP UDP 服务。
1 | // 此时 address 设置已经完成 省略... |
⭐️ 运行结果:
两个客户端的发送数据都是 const char *sendbuf = "hello!\n";
TCP 回声结果:
udp 回声结果:
服务器代码: 1_3_server.cpp
客户端代码: 1_3_client_udp.cpp
、 1_3_client_udp.cpp
详细代码见仓库 🏡 : Linux_Server_Programming_emamples
¶1.4 LT 和 ET 模式
epoll 对文件描述符的操作有两种模式:LT 模式和 ET 模式。LT 模式是默认的工作模式,这种模式下 epoll 相当于一个效率较高的 epoll。当往 epoll 内核事件表中注册一个文件描述符上的 EPOLLET 事件时,epoll 将以 ET 模式来操作该文件描述符。ET 模式是 epoll 的高效工作模式。
对于采用 LT 工作模式的文件描述符,当 epoll_wait 检测到其上有事件发生并将此事件通知应用程序后,应用程序可以不立即处理该事件。这样,当应用程序下一次调用 epoll_wait 时,epoll_wait 还会再次向应用程序通知此事件,知道该事件被处理。而对于采用 ET 工作模式的文件描述符,当 epoll_wait 检测到其上有事件发生并将此事件通知应用程序后,应用程序必须立即处理该事件,因此后续的 epoll_wait 调用将不再向应用程序通知这一事件。可见,ET 模式在很大程度上降低了同一个 epoll 事件被重复触发的次数,因此效率要比 LT 模式高。
但是也正是因为内核只会向应用程序通知一次,所以对于连续的数据多次发送的情况下,需要通过其他的机制通知应用程序。
1 | // 会通过 errno == EAGAIN 或者 EWOULDBLOCK 来判断接受是否完成 |
⭐️ 运行结果:(发送长度超过 BUFFFER_SIZE 长度的消息,见下)
🔹 LT 模式:
可以发现随着每次的 epoll_wait 的调用,都会触发打印 event trigger once 语句。
🔸 ET 模式:
可以发现后面的 epoll_wait 的调用,并不会触发打印 event trigger once 语句,最后会出现 errno == EAGAIN
打印 read later 语句
服务器代码: 1_4_server.cpp
客户端代码: 1_2_chat_client.cpp
详细代码见仓库 🏡 : Linux_Server_Programming_emamples
¶1.5 使用 EPOLLONESHOT 事件
即使使用 ET 模式,一个 socket 上的某个事件还是可能被触发多次。比如一个线程在读取完某个 socket 上的数据后开始处理这些数据,而在数据的处理过程中该 socket 上又有新数据可读(EPOLLIN 再次被触发),此时另一个线程被唤醒来读取这些新的数据。于是就出现了两个线程同时操作一个 socket 的局面。这当然不是被期望的,我们期待的是一个 socket 连接在任一时刻都只被一个线程处理。可以用 epoll 的 EPOLLONESHOT 事件实现。
对于注册了 EPOLLONESHOT 事件的文件描述符,操作系统最多触发其上注册的一个可读、可写或者异常事件,且只触发一次,除非我们使用 epoll_ctl 函数重置该文件描述符上注册的 EPOLLONESHOT 事件。这样,当一个线程在处理某个 socket 时,其他线程是不可能有机会操作该 socket 的。
但是需要注意 🙋♂ :注册了 EPOLLONESHOT 事件的 socket 一旦被某个线程处理完毕,该线程就应该立即重置这个 socket 上的 EPOLLONESHOT 事件,以确保这个 socket 下一次可读时,其 EPOLLIN 事件能被触发,进而让其他工作线程有机会继续处理这个 socket。
服务器 工作线程函数处理完某个 socket 上的一次请求之后,又接收到该 socket 上新的客户请求,则该线程将继续为这个 socket 服务。并且因为该 socket 上注册了 EPOLLONESHOT 事件,其他线程没有机会接触这个 socket,如果工作线程等待 5s 后仍然没有收到该 socket 上的下一批客户数据,则它将放弃为该 socket 服务,同时调用 reset_oneshot
函数来重置该 socket 上的注册事件,这将使 epoll 有机会再次检测到该 socket 上的 EPOLLIN 事件,进而使得其他线程有机会为该 socket 服务。
⭐️ 运行结果:
服务器代码: 1_5_server.cpp
客户端代码: 1_5_client.cpp
详细代码见仓库 🏡 : Linux_Server_Programming_emamples
¶2 有限状态机
本节是一个有限状态机应用的一个实例:HTTP 请求的读取和分析。很多网络协议,包括 TCP 协议和 IP 协议,都在其头部中提供头部长度字段。程序根据该字段的值就可以知道是否接收到一个完整的协议头部。但 HTTP 协议并未提供这样的头部长度字段,并且其头部长度变化也很大,可以只有十几字节,也可以有上百字节。根据协议规定,判断 HTTP 头部结束的依据是遇到一个空行,该空行仅包含一对回车换行符(<CR><LF>
,或者 \r\n
)。如果一次读操作没有读入 HTTP 请求的整个头部,即没有遇到空行,那么就必须等待客户继续写数据并再次读入。因此,每完成一次读操作,就要分析新读入的数据中是否有空行。不过在寻找空行的过程中,程序可以同时完成对整个 HTTP 请求头部的分析,以提高解析 HTTP 请求的效率。
以下代码使用主、从两个有限状态机实现了最简单的 HTTP 请求的读取和分析,为了表述简洁,直接称 HTTP 请求的一行(包括请求行和头部字段)为行。
🔹 从状态机,用于解析出一行内容,从状态机的转换图如下所示:
🔸 HTTP 请求的入口函数(主状态机)
主状态机使用 checkstate 变量来记录当前状态。如果当前的状态是 CHECK_STATE_REQUESTLINE,则表示 parse_line
函数解析出的行是请求行,于是主机状态机调用 parse_requestline
来分析请求行;如果当前的状态时 CHECK_STATE_HEADER,则表示 parse_line
函数解析出的是头部字段,于是主机状态调用 parse_headers
来分析头部字段。checkstate 变量的初始值是 CHECK_STATE_REQUESTLINE,parse_requestline
函数在成功地分析完请求行之后将其设置为 CHECK_STATE_HEADER,从而实现状态转移。
🔹 分析请求行
请求行主要分析:请求方法,URL,协议版本。
1 | // 请求方法分析 |
🔸 分析头部字段
1 | // 遇到一个空行,说明得到一个正确地 HTTP 请求 |
⭐️ 运行结果:
🔹 客户端发送请求一:(支持 GET 操作)
1 | const char *buf = "GET /http://example/hello.html HTTP/1.1\r\n\r\nHost:hostlocal"; |
🔸 客户端发送请求二: (不支持 POST 操作)
1 | const char *buf = "POST /http://example/hello.html HTTP/1.1\r\n\r\nHost:hostlocal"; |
服务器代码: GET_HOST_test.cpp
,客户端代码:2_1_client.cpp
详细代码见仓库 🏡 : Linux_Server_Programming_emamples
¶3 统一事件源
信号是一种 异步 事件:信号处理函数和程序的主循环是两条不同的执行路线。很显然,信号处理函数需要尽可能快地执行完毕,以确保该信号不被屏蔽太久。一种典型地解决方案是:把信号的主要处理逻辑放到程序地主循环,主循环再根据接收到的信号值执行目标信号对应的逻辑代码。信号处理函数通常使用 管道 来将信号“传递”给主循环:怎么知道管道上何时有数据可读?这很简单,只需要使用 IO 复用系统调用来监听管道的读端文件描述符上的可读事件。如此一来,信号事件就能和其他 IO 事件一样被处理,即统一事件源。
1 | // ~ 监听管道读端 读事件 |
⭐️ 运行结果:
服务器运行后,键入 CTRL + C 接收到中断信号。
服务器代码: 1_3_server.cpp
详细代码见仓库 🏡 : Linux_Server_Programming_emamples
¶4 定时器
本节通过一个实例——处理非活动连接,来介绍如何使用 SIGALRM 信号定时。不过,需要先给出一种简单的定时器实现——基于升序链表的定时器,并把它应用到处理非活动连接这个实例中。
¶4.1 基于升序链表的定时器
定时器通常至少包含两个成员:一个超时时间和一个任务回调函数。有时候还可能包含回调函数被执行时需要传入的参数,以及是否重启定时器等信息。如果使用链表作为容器来串联所有的定时器,则每个定时器还要包含指向下一个定时器的指针成员。进一步,如果链表是双向的,则每个定时器还需要包含指向前一个定时器的指针成员。
🔹 所需数据结构:
1 | // 用户数据结构: 客户端 socket 地址、socket 文件描述符、读缓存和定时器 |
🔸 定时器链表成员函数:
1 | // 有序链表的插入、删除和调整操作都需要判断头尾节点。 |
将所有实现包含在头文件中,其核心函数 tick 相当于一个心搏函数,它每隔一段固定的时间就执行一次,以检测并处理到期的任务。判断定时任务到期的依据是定时器的 expire 值小于当前的系统时间。从执行效率来看,添加定时器的时间复杂度是 $O(n)$ ,删除定时器的时间复杂度是 $O(1)$ ,执行定时任务的时间复杂度是 $O(1)$ 。
头文件: lst_timer.h
详细代码见仓库 🏡 : Linux_Server_Programming_emamples
¶4.2 处理非活动连接
服务器利用 alarm 函数周期性地触发 SIGALRM 信号,该信号的信号处理函数利用管道通知主循环执行定时器链表上的定时任务——关闭非活动的连接。(利用管道通知主循环信号的方法,见统一事件源);
服务器代码处理大于 3 * TIMESLOT
时间非活动的客户端。每个客户端都有一个定时器,定时器记录了最后一次客户端的活动时间,每次客户连接上有数据可读,则需要调整该连接对应的定时器,以延迟该连接被关闭的时间,但同时也可能是删除操作。
🔹 服务器设置客户端定时器
1 | // 客户端设置定时器 |
🔸 有数据可读,调整定时器
1 | // 有数据可读,调整定时器 |
⭐️ 运行结果:( 关闭时间: 3 * TIMESLOT
)
测试条件一:
1 | const char *sendbuf = "hello\n"; |
测试条件二:
1 | const char *sendbuf = "hello\n"; |
所以测试条件一,该客户端可以一直保持连接。
所以测试条件二,由于客户端超过 15 s 没有活动,所以被客户端强制关闭连接。
头文件: lst_timer.h
,服务器代码:4_2_server.cpp
,客户端代码: 4_2_client.cpp
详细代码见仓库 🏡 : Linux_Server_Programming_emamples
¶5 共享内存
将之前的聊天室服务器程序,修改为一个多进程服务器:一个子进程处理一个客户连接。同时,我们将所有客户 socket 连接的都缓冲设计为一块共享内存。
整个 通信建立流程 分为了三个部分:
🔹 客户端发起连接请求,服务器处理连接请求,并完成连接建立
1 | if (sockfd == listenfd) { // ~ 监听连接时间 |
🔸 服务器为该连接创建子进程(继承 connfd ),并关闭父进程 connfd ,将与客户通信操作交给子进程。
1 | pid_t pid = fork(); |
🔹 父子进程间通过管道连接,子进程通知父进程有数据传来需要被处理,父进程通知子进程发送处理后的数据。主要通过子进程来作为中转站,子进程需要同时监听客户端 socket,以及与父进程之间的管道。
1 | // ==== 主循环中 ==== |
注意 🙋♂ :还需要创建新的管道来通知主循环处理信号(统一事件源的处理方法)。
1 | else if ((sockfd == sig_pipefd[0]) && (events[i].events & EPOLLIN)) {...} |
共享内存 :
🔹 本服务器代码使用的是共享内存的 POSIX 方法:
1 | shmfd = shm_open(shm_name, O_CREAT | O_RDWR, 0666); // ~ 创建共享内存 |
注意 🙋 :在编译的时候需要指定链接选项 -lrt
1 | g++ -o 5_2_server 5_2_server.cpp -lrt |
🔸 另外还可以使用 sys/shm.h
中的 shmget 、 shmat 、 shmdt 和 shmctl 系统调用。本人认为更简单一些 🤘 ,操作更方便。
1 | char *share_men; |
注意 🙋
🔹 虽然使用了共享内存,但每个子进程都只会往自己所处理的客户链接对应的那一部分读缓存中写入数据,所以使用共享内存的目的只是为了“共享读”。所以,每个子进程在使用共享内存的时候都无需加锁,这样符合“聊天室服务器”的应用场景,同时提高了程序性能。
🔸 服务器程序在启动的时候给数组 users 分配了足够多的空间,使得它可以存储所有可能的用户连接相关的数据。同样一次性给数组 sub_process 分配的空间也足以存储所有可能的子进程的相关数据。这是牺牲空间换取时间的又一例子。
⭐️ 运行结果:
🔹 用户数超过上限:
![](Linux高性能服务器编程examples/5_1_result_too_many_users (1).png)
![](Linux高性能服务器编程examples/5_1_result_too_many_users (2).png)
🔸 正常实现聊天室功能:(一个服务器,四个客户端)
前三个客户端分别键入一条语句,最后一个客户端仅仅查看聊天记录。
🔹 服务器关闭连接
![](Linux高性能服务器编程examples/5_1_result_too_many_users (2).png)
服务器代码:5_1_server.cpp
,客户端代码: 5_1_client.cpp
详细代码见仓库 🏡 : Linux_Server_Programming_emamples
¶6 进程池与线程池
¶6.1 半同步/半异步进程池
为了避免在父、子进程之间传递文件描述符,将接收连接的操作放到子进程中。
🔹 子进程的类
1 | class process { |
🔸 进程池类
1 | // 进程池类,将它定义为模板类是为了代码复用 |
🔹 进程池构造函数
1 | // 进程池构造函数,参数 listenfd 是监听 socket, 它必须在创建进程池之前被创建,否则子进程无法直接引用它,参数 process_number 指定进程池中子进程的数量 |
🔸 进程池运行程序
1 | // 父进程中 m_idx 值为 -1, 子进程中 m_idx 值大于等于 0 |
🔹 父进程运行函数,主要用来监听 m_listenfd,并通过管道通知子进程与对应 socket 建立连接。
1 | if (sockfd == m_listenfd) { |
🔸 子进程运行函数,主要用来监听 pipefd,通过父进程的通知,与对应客户端建立连接。后注册客户端 socket,同时监听客户端 socket,具体的处理方式交给对应的用户处理函数。
1 | // 建立连接 |
线程池头文件:processpool.h
详细代码见仓库 🏡 : Linux_Server_Programming_emamples
¶6.2 用进程池实现简单的 CGI 服务器
基于 processpool.h
实现简单的 CGI 服务器。
1 | // 用于处理客户 CGI 请求的类,它可以作为 processpool 类的模板参数 |
🔹 cgi 处理函数
1 | void process() { |
cgi 程序:(需要编译生成可执行文件)
1 |
|
⭐️ 运行结果:
进程池头文件:processpool.h
、服务器程序: 6_1_CGI_server.cpp
、客户端程序: 6_1_client.cpp
、CGI 处理函数 cgi.c
详细代码见仓库 🏡 : Linux_Server_Programming_emamples
¶6.3 半同步/半反应堆线程池
相比进程池实现,该线程池地通用性要高得多,因为它使用一个工作队列完全解除了主线程和工作线程的耦合关系:主线程往工作队列中插入任务,工作线程通过竞争来取得任务并执行它。但是与此同时,队列的 push、pop 操作需要用到线程同步机制,利用了书中第十四章介绍的线程同步机制的包装类。
操作工作队列需要加锁,如 append 、 run 操作。
🔹 append 操作
1 | template<typename T> |
🔸 run 操作
1 | template<typename T> |
注意 🙋♂
在 C++ 程序中使用 pthread_create 函数时,该函数的第 3 个参数必须指向一个静态函数。而要在一个静态函数中使用类的动态成员(包括成员函数和成员变量),则只能通过如下两个方式来实现:
◻️ 通过类的静态对象来调用。比如单例模式,静态函数可以通过类的全局唯一实例来访问动态成员函数。
◻️ 将类的对象作为参数传递给该静态函数,然后再静态函数中引用这个对象,并调用其动态方法。
上面 👆 代码使用的是 第二种 方式:将线程参数设置为 this 指针,然后在 worker 函数中获取该指针并调用其动态方法 run 。
1 | pthread_create(m_threads + i, NULL, worker, this) |
线程池头文件:threadpool.h
、线程同步机制包装类: locker.h
详细代码见仓库 🏡 : Linux_Server_Programming_emamples
¶7 简单 Web 服务器
¶7.1 http_conn 类
基于第二节使用有限状态机实现的解析 HTTP 请求的服务器,利用线程池来重新实现一个并发的 Web 服务器。
首先需要准备一个线程池的模板参数类,用以封装逻辑的处理,这个类就是 http_conn 类。
其头文件为:
http_conn.h
,实现文件为:http_conn.cpp
🔹 状态机的设定同第二节分析,基本相同。
🔸 HTTP 请求的入口函数
1 | // 线程池中 run 函数会阻塞等待任务, 如果被分配任务,就会执行任务的 process() 函数 |
🔹 http 请求解析处理:有限状态机。 process_read()
🔸 http 响应消息处理:根据状态码发送不同的信息。 process_write()
更多详细实现见仓库 🏡 : Linux_Server_Programming_emamples
¶7.2 main 函数
main 函数主要负责 IO 读写。
🔹 创建线程池
1 | // 模板参数 T --> http_conn |
🔸 预先为每个可能的客户连接分配一个 http_conn 对象
1 | http_conn *users = new http_conn[MAX_FD]; |
🔹 主循环用来接收客户请求连接
1 | if (sockfd == listenfd) { |
🔸 根据读事件,决定将任务添加到线程池,还是关闭连接
1 | else if (events[i].events & EPOLLIN) { |
🔹 异常事件,直接关闭客户连接
1 | else if (events[i].events & (EPOLLRDHUP | EPOLLHUP | EPOLLERR)) { |
🔸 根据写的结果,决定是否关闭连接
1 | else if (events[i].events & EPOLLOUT) { |
基本流程图:(图太大了,分成三个)
总流程图见最后,图片源见仓库图片文件夹。
¶7.3 压力测试
压力测试程序有很多种实现方式,比如 IO 复用方式,多线程、多进程并发编程方式,以及这些方式的结合使用。不过单纯的IO复用方式的施压程度最高,因为线程和进程的调到也是要占用一定CPU时间的。因此,下面将使用 epoll 来实现一个通用的服务器压力测试程序,恰好该程序作为 Web 服务器程序的客户端。
🔹 向服务器发起 num 个 TCP 连接 (可以通过改变 num 来调整测试压力)
1 | void start_conn(int epoll_fd, int num, const char *ip, int port) { |
🔸 每个客户连接不停向服务器发送同样请求 (GET 请求)
1 | static const char *request = "GET http://localhost/index.html HTTP/1.1\r\nConnection: keep-alive\r\n\r\nxxxxxxxxxxxxxx"; |
🔹 向服务器写入 len 字节的数据
1 | bool write_nbytes(int sockfd, const char *buffer, int len) { |
🔸 从服务器读数据
1 | bool read_once(int sockfd, char *buffer, int len) { |
🔹 通过 EPOLLIN 与 EPOLLOUT 之间的转换实现,反复发送请求,接收应答的过程。
1 | if (events[i].events & EPOLLIN) { |
⭐️ 测试结果:
🔹 服务器启动八个线程:
🔸 客户端启动,并开始与服务器建立连接 (1000 个,间隔时间 10 ms )
🔹 客户端建立连接后,发送 http 请求,并接收服务器返回的 http 应答消息。
🔸 服务器接收 http 请求并解析,最后给客户端应答消息。
如果 Web 服务器程序足够稳定,那么 websrv 和 stress_test 这两个程序将一直运行下去,并不断交换数据。
线程池头文件:threadpool.h
、线程同步机制包装类: locker.h
、 http_conn 类头文件: http_conn.h
、 http_conn 类实现文件: http_conn.cpp
、服务器代码: main.cpp
、测试程序代码(客户端代码): stress_test.cpp
,项目编译文件:Makefile
1 | # 编译 |
详细代码见仓库 🏡 : Linux_Server_Programming_emamples
项目流程 总图 : ⭐️