【Linux】进程池实例

news2025/1/11 22:51:45

这篇博客讲解了进程池的创建过程,并在最后附上了完整代码。

现在有一个父进程,然后提前创建出一批子进程,未来如果父进程master有一些任务要交给子进程去运行,而不用像shell,需要执行命令才回去创建进程,创建进程本身也是有成本的。父进程要把任务派发给一些子进程,注定了要进行通信,我们可以提前给每一个进程创建管道,由父进程持有写端,子进程持有读端。我们有了管道这种技术,就可以让父进程通过管道将任务传递给子进程,想让哪个进程执行任务,就给哪个管道写入任务,我们把提前创建的这批进程叫做进程池, 这种预先创建的进程就可以大大减少未来执行任务时创建进程的成本。master只负责往管道写任务,子进程只会等待任务的到来,一旦来了就会处理。如果父进程不往管道里写任务,管道里没数据,管道读写端的文件描述符也没关,子进程会阻塞等待,等待任务的道理!!master向哪一个管道写入,就是唤醒哪一个子进程来处理任务

这样就通过管道实现了进程的协同,可以由父进程定向唤醒一个或多个进程。我们在给子进程分配任务时,不能让一个特别忙而是让它们均衡一些,父进程在进行任务划分时要做到划分的负载均衡

我们站在父进程的角度,创建一个信道类Channel,

//master
class Channel
{

private:
    int _wfd;
    pid_t _subprocessid;
    std::string _name;
};

其中,_wfd是管道的写入端,_subprocessid是对应子进程的id,_name表示管道的名字。

并通过vector来管理管道:

std::vector<Channel> channels;

有一个提示需要交代一下,在C++中,我们的形参命名规范:

const &:输入型参数

& :输入输出型参数

*  :输出型参数

我们接下来就是创建信道和子进程,并把它们打印出来,看看我们的代码框架有没有问题:

#include <iostream>
#include <string>
#include <vector>
#include <sys/types.h>
#include <unistd.h>

void work(int rfd)
{
    while(true)
    {
        sleep(1);
    }

}

//master
class Channel
{
public:
    Channel(int wfd,pid_t id,const std::string& name)
        :_wfd(wfd)
        ,_subprocessid(id)
        ,_name(name)
    {
    }

    int GetWfd() {return _wfd ;}
    pid_t GetProcessId(){return _subprocessid;}
    std::string GetName(){return _name;}

    ~Channel()
    {

    }

private:
    int _wfd;
    pid_t _subprocessid;
    std::string _name;
};

void CreateChannelAndSub(int num, std::vector<Channel>* channels)
{
    for(int i = 0; i<num ; i++)
    {
        //1.创建管道
        int pipefd[2] = {0};
        int n = pipe(pipefd);
        if(n < 0) exit(1);

        //2.创建子进程
        pid_t id = fork();
        if(id == 0)
        {
            //child
            close(pipefd[1]);
            work(pipefd[0]);
            close(pipefd[0]);
            exit(0);
        }
        //father
        close(pipefd[0]);
        //a.子进程的pid  b.父进程关心的管道的w端

        //3.构造一个通道的名字
        std::string channel_name = "Channel-" + std::to_string(i);
        channels->push_back(Channel(pipefd[1],id,channel_name));
    }

}

// ./processpool 5
int main(int argc,char* argv[])
{
    if(argc != 2)
    {
        std::cerr << "Usage: " << argv[0] << " processnum" << std::endl;
        return 1;
    }
    std::vector<Channel> channels;
    int num = std::stoi(argv[1]);
    //1.创建信道和子进程
    CreateChannelAndSub(num, &channels);

    //for test
    for(auto& channel : channels)
    {
        std::cout<<"==============================="<<std::endl;
        std::cout<<channel.GetName()<<std::endl;
        std::cout<<channel.GetWfd()<<std::endl;
        std::cout<<channel.GetProcessId()<<std::endl;
    }

    sleep(100);
    return 0;
}

通过运行程序,我们给的命令行参数是10,创建10个子进程,然后打开进程监测窗口:

可以看到我们创建管道和子进程成功,非常好!

我们搭建好框架后,接下来就要通过channel控制子进程、回收管道和子进程。

父进程需要给子进程发任务,那任务是什么呢?父进程是没有办法把函数发送到管道里的,而任务其实就是让子进程执行某段代码,而父子进程数据可以写时拷贝,但是代码是共享的,所以,我们要构建任务,我们可以由父进程预先规定一些任务,这些任务本质就是一张表(函数指针数组),保存了各种方法的地址,未来我们就可以往管道里写固定长度的4字节的数组下标(任务码),所以,我们现在要转过头构建一批任务,为了方便,我们创建Task.hpp文件,.hpp允许声明和实现写在一个头文件里,.hpp文件的缺点是无法形成库,只能开源式地给别人,一般在开源项目里会用到:

#pragma once

#include <iostream>
#include <stdlib.h>
#include <ctime>
#include <sys/types.h>
#include <unistd.h>

#define TaskNum 3

typedef void (*task_t)(); // task_t 函数指针类型

void Print()
{
    std::cout << "I am print task" << std::endl;
}

void DownLoad()
{
    std::cout<< "I am a download task" << std::endl;
}

void Flush()
{
    std::cout<< "I am a flush task" << std::endl;
}

task_t tasks[TaskNum];

void LoadTask()
{
    srand(time(nullptr) ^ getpid() ^ 1642);
    tasks[0] = Print;
    tasks[1] = DownLoad;
    tasks[2] = Flush;
}

void ExcuteTask(int number)
{
    if(number < 0 || number > 2) return;
    tasks[number]();
}

int SelectTask()
{
    return rand() % TaskNum;
}


在我们的代码中,按顺序给各个子进程发送任务,这种叫轮询方案,以下是我们的具体实现代码:

#include <iostream>
#include <string>
#include <vector>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include "Task.hpp"

void work(int rfd)
{
    while(true)
    {
        int command = 0;
        int n = read(rfd,&command,sizeof(command));
        if(n == sizeof(int))
        {
            std::cout << "pid is : " << getpid() << " handler task" << std::endl;
            ExcuteTask(command);
        }
        else if(n == 0)
        {
            std::cout << "sub process : " << getpid() << "quit" << std::endl;
            break;
        }
    }
}

//master
class Channel
{
public:
    Channel(int wfd,pid_t id,const std::string& name)
        :_wfd(wfd)
        ,_subprocessid(id)
        ,_name(name)
    {
    }

    int GetWfd() {return _wfd ;}
    pid_t GetProcessId(){return _subprocessid;}
    std::string GetName(){return _name;}

    void CloseChannel()
    {
        close(_wfd);
    }

    void Wait()
    {
        pid_t rid = waitpid(_subprocessid, nullptr, 0);
        if(rid > 0)
        {
            std::cout << "wait " << rid << " success" << std::endl;
        }
    }

    ~Channel()
    {
    }

private:
    int _wfd;
    pid_t _subprocessid;
    std::string _name;
};

void CreateChannelAndSub(int num, std::vector<Channel>* channels)
{
    for(int i = 0; i<num ; i++)
    {
        //1.创建管道
        int pipefd[2] = {0};
        int n = pipe(pipefd);
        if(n < 0) exit(1);

        //2.创建子进程
        pid_t id = fork();
        if(id == 0)
        {
            //child
            close(pipefd[1]);
            work(pipefd[0]);
            close(pipefd[0]);
            exit(0);
        }
        //father
        close(pipefd[0]);
        //a.子进程的pid  b.父进程关心的管道的w端

        //3.构造一个通道的名字
        std::string channel_name = "Channel-" + std::to_string(i);
        channels->push_back(Channel(pipefd[1],id,channel_name));
    }

}

int NextChannel(int channelnum)
{
    static int next = 0;
    int channel = next;
    next++;
    next %= channelnum;
    return channel;
}

void SendTaskCommand(Channel& channel, int taskcommand)
{
    write(channel.GetWfd(),&taskcommand,sizeof(taskcommand));
}

void ctrlProcessOnce(std::vector<Channel>& channels)
{
    sleep(1);

    
    //a. 选择一个任务
    int taskcommand = SelectTask();
    //b. 选择一个信道和进程
    int channel_index = NextChannel(channels.size());
    //c. 发送任务
    SendTaskCommand(channels[channel_index],taskcommand);
    std::cout << std::endl;
    std::cout << "taskcommand: " << taskcommand << " channel: " \
    << channels[channel_index].GetName() << " sub process: " << channels[channel_index].GetProcessId() << std::endl;
}

void ctrlProcess(std::vector<Channel>& channels, int times = -1)
{
    if(times > 0)
    {
        while(times--)
        {
            ctrlProcessOnce(channels);
        }
    }
    else
    {
        while(true)
        {
            ctrlProcessOnce(channels);
        }
    }
    
}

void CleanUpChannel(std::vector<Channel>& channels)
{
    for(auto& channel : channels)
    {
        channel.CloseChannel();
    }
    for(auto& channel : channels)
    {
        channel.Wait();
    }
}

// ./processpool 5
int main(int argc,char* argv[])
{
    if(argc != 2)
    {
        std::cerr << "Usage: " << argv[0] << " processnum" << std::endl;
        return 1;
    }
    std::vector<Channel> channels;
    int num = std::stoi(argv[1]);
    LoadTask();
    //1.创建信道和子进程
    CreateChannelAndSub(num, &channels);

    //2.通过channel控制子进程
    ctrlProcess(channels,num);

    //3.回收管道和子进程 a.关闭所有的写端 b.回收子进程
    CleanUpChannel(channels);
    
    sleep(5);
    return 0;
}

其中,上面的代码有两个小细节处理:

1.在创建子进程时, ,这样就可以让子进程不关心管道读端,只需要从标准输入读就行。

这样就可以将管道的逻辑和子进程执行任务的逻辑解耦

2.子进程要执行的word本身就是一个任务,可以作为CreateChannelAndSub的参数传入,在然后回调work。其中task_t task叫做回调函数,未来所有子进程都回去调用传入的task。这样之后,进程池本身的代码和任务本身两个文件就彻底解耦了!(把work函数放到Task.hpp )

 可是现在我们的代码还存在一个BUG,我们来看,

里面写了两个循环,不可以放到一个循环里吗?

 我们发现,随着管道的创建,越来越多的写端指向第一个管道,如果创建了10个子进程,那就有10个写端指向第一个管道。所以,如果两个循环写到一起,就会从头向后关管道的文件描述符,第一个关完后,还是有九个文件描述符指向第一个管道,管道中对文件描述符有引用计数,此时,这个管道并没有向我们预期的那样退出,写端没有关完,读端什么都读不到,读端依旧阻塞,子进程不退出,进程就阻塞了。

那为什么写成两个循环就可以呢?因为当关掉最后一个管道时,最后一个子进程指向上一个管道的写端就被释放了,类似于递归,从下往上就关掉了。

我们现在意识到了这个问题,那我们就可以倒着先关闭最下面的管道,

void CleanUpChannel(std::vector<Channel>& channels)
{
    int num = channels.size()-1;
    while(num >= 0)
    {
        channels[num].CloseChannel();
        channels[num--].Wait();
    }
}

这样做是没有问题的,但是我们并没有从源头上解决这个bug,我们就不应该让这种情况发生,我们的想法是这样的,如果是第二次及之后创建子进程时,*channels数组一定不为空,里面一定包含写端,那就把里面的每一个管道的写端关闭一下就好:

完整的进程池代码附上:

Makefile

processpool:ProcessPool.cc
	g++ -o $@ $^ -std=c++11
.PHONY:clean
clean:
	rm -f processpool

Task.hpp

#pragma once

#include <iostream>
#include <stdlib.h>
#include <ctime>
#include <sys/types.h>
#include <unistd.h>

#define TaskNum 3

typedef void (*task_t)(); // task_t 函数指针类型

void Print()
{
    std::cout << "I am print task" << std::endl;
}

void DownLoad()
{
    std::cout<< "I am a download task" << std::endl;
}

void Flush()
{
    std::cout<< "I am a flush task" << std::endl;
}

task_t tasks[TaskNum];

void LoadTask()
{
    srand(time(nullptr) ^ getpid() ^ 1642);
    tasks[0] = Print;
    tasks[1] = DownLoad;
    tasks[2] = Flush;
}

void ExcuteTask(int number)
{
    if(number < 0 || number > 2) return;
    tasks[number]();
}

int SelectTask()
{
    return rand() % TaskNum;
}

void work()
{
    while(true)
    {
        int command = 0;
        int n = read(0,&command,sizeof(command));
        if(n == sizeof(int))
        {
            std::cout << "pid is : " << getpid() << " handler task" << std::endl;
            ExcuteTask(command);
        }
        else if(n == 0)
        {   
            std::cout << "sub process : " << getpid() << "quit" << std::endl;
            break;
        }
    }
}


ProcessPool.cc

#include <iostream>
#include <string>
#include <vector>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include "Task.hpp"

// void work(int rfd)
// {
//     while(true)
//     {
//         int command = 0;
//         int n = read(rfd,&command,sizeof(command));
//         if(n == sizeof(int))
//         {
//             std::cout << "pid is : " << getpid() << " handler task" << std::endl;
//             ExcuteTask(command);
//         }
//         else if(n == 0)
//         {
//             std::cout << "sub process : " << getpid() << "quit" << std::endl;
//             break;
//         }
//     }
// }

// master
class Channel
{
public:
    Channel(int wfd, pid_t id, const std::string &name)
        : _wfd(wfd), _subprocessid(id), _name(name)
    {
    }

    int GetWfd() { return _wfd; }
    pid_t GetProcessId() { return _subprocessid; }
    std::string GetName() { return _name; }

    void CloseChannel()
    {
        close(_wfd);
    }

    void Wait()
    {
        pid_t rid = waitpid(_subprocessid, nullptr, 0);
        if (rid > 0)
        {
            std::cout << "wait " << rid << " success" << std::endl;
        }
    }

    ~Channel()
    {
    }

private:
    int _wfd;
    pid_t _subprocessid;
    std::string _name;
};

void CreateChannelAndSub(int num, std::vector<Channel> *channels, task_t task)
{
    for (int i = 0; i < num; i++)
    {
        // 1.创建管道
        int pipefd[2] = {0};
        int n = pipe(pipefd);
        if (n < 0)
            exit(1);

        // 2.创建子进程
        pid_t id = fork();
        if (id == 0)
        {
            // 第二次及之后创建管道
            if (!channels->empty())
            {
                for (auto &channel : *channels)
                {
                    channel.CloseChannel();
                }
            }
            // child
            close(pipefd[1]);
            dup2(pipefd[0], 0); // 将管道的读端,重定向到标准输入
            task();
            close(pipefd[0]);
            exit(0);
        }
        // father
        close(pipefd[0]);
        // a.子进程的pid  b.父进程关心的管道的w端

        // 3.构造一个通道的名字
        std::string channel_name = "Channel-" + std::to_string(i);
        channels->push_back(Channel(pipefd[1], id, channel_name));
    }
}

int NextChannel(int channelnum)
{
    static int next = 0;
    int channel = next;
    next++;
    next %= channelnum;
    return channel;
}

void SendTaskCommand(Channel &channel, int taskcommand)
{
    write(channel.GetWfd(), &taskcommand, sizeof(taskcommand));
}

void ctrlProcessOnce(std::vector<Channel> &channels)
{
    sleep(1);

    // a. 选择一个任务
    int taskcommand = SelectTask();
    // b. 选择一个信道和进程
    int channel_index = NextChannel(channels.size());
    // c. 发送任务
    SendTaskCommand(channels[channel_index], taskcommand);
    std::cout << std::endl;
    std::cout << "taskcommand: " << taskcommand << " channel: "
              << channels[channel_index].GetName() << " sub process: " << channels[channel_index].GetProcessId() << std::endl;
}

void ctrlProcess(std::vector<Channel> &channels, int times = -1)
{
    if (times > 0)
    {
        while (times--)
        {
            ctrlProcessOnce(channels);
        }
    }
    else
    {
        while (true)
        {
            ctrlProcessOnce(channels);
        }
    }
}

void CleanUpChannel(std::vector<Channel> &channels)
{
    int num = channels.size() - 1;
    while (num >= 0)
    {
        channels[num].CloseChannel();
        channels[num--].Wait();
    }
    // for(auto& channel : channels)
    // {
    //     channel.CloseChannel();
    // }
    // for(auto& channel : channels)
    // {
    //     channel.Wait();
    // }
}

// ./processpool 5
int main(int argc, char *argv[])
{
    if (argc != 2)
    {
        std::cerr << "Usage: " << argv[0] << " processnum" << std::endl;
        return 1;
    }
    std::vector<Channel> channels;
    int num = std::stoi(argv[1]);
    LoadTask();
    // 1.创建信道和子进程
    CreateChannelAndSub(num, &channels, work);

    // 2.通过channel控制子进程
    ctrlProcess(channels, num);

    // 3.回收管道和子进程 a.关闭所有的写端 b.回收子进程
    CleanUpChannel(channels);

    sleep(5);
    return 0;
}

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2077893.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

气膜建筑与装配式建筑的对比分析—轻空间

在现代建筑中&#xff0c;气膜建筑和装配式建筑都作为新型建筑形式受到关注。然而&#xff0c;在很多应用场景中&#xff0c;气膜建筑展现出了比装配式建筑更为明显的优势。以下将着重对比气膜建筑相较于装配式建筑的独特优势。 气膜建筑的突出优势 1. 更快的施工速度 气膜建筑…

信号量笔记

1、信号量简介 信号量是一种实现任务间通信的机制&#xff0c;可以实现任务之间同步或临界资源的互斥访问&#xff0c;常用于协助一组相互竞争的任务来访问临界资源。在多任务系统中&#xff0c;各任务之间需要同步或互斥实现临界资源的访问&#xff0c;信号量功能可以为用户提…

智能新时代:探索【人工智能】、【机器学习】与【深度学习】的前沿技术与应用

目录 1. 引言 1.1 人工智能的概念与历史 1.2 机器学习与深度学习的演进 1.3 计算机视觉的崛起与应用场景 2. 人工智能基础 2.1 什么是人工智能&#xff1f; 2.2 人工智能的分类 2.3 人工智能的现实应用 3. 机器学习 3.1 机器学习的定义与基本原理 3.2 机器学习的主要…

【可能是全网最丝滑的LangChain教程】二十二、LangChain进阶之Callbacks(完结篇)

这是LangChain进阶教程的最后一篇&#xff0c;Let’s get it!!! 01 Callback介绍 在LangChain中&#xff0c;Callback 是一种非常重要的机制&#xff0c;它允许用户监听和处理在执行链式任务 (Chain) 过程中的各种事件。这包括但不限于开始执行、结束执行、异常处理等。Callba…

数据结构(邓俊辉)学习笔记】串 03——KMP算法:记忆法

文章目录 1. 重复匹配的前缀2. 不变性3. 记忆力4. 预知力 1. 重复匹配的前缀 关于串匹配&#xff0c;包括蛮力算法在内&#xff0c;至少有30多种知名的算法&#xff0c;而接下来&#xff0c;就将介绍其中最为经典的 KMP 算法。这个算法之所以著名&#xff0c;不仅是由于它出自包…

Autosar(Davinci) --- ADT和IDT如何Mapping

前言 这里我们讲一下ADT如何与IDT进行Mapping 一、ADT为什么要与IDT进行Mapping 二、ADT和IDT如何Mapping 鼠标右键【type Mapping Sets】,选择【New Data type Mapping Set...】 打开之后,我们起一个名字【DemoTypeMapping】 然后选择【Data Type Maps】来将ADT与IDT进行m…

SpringBoot+Grafana+Prometheus+Docker-Compose 快速部署与JVM监控的快速入门的简单案例

1. Java项目 1.1 项目结构 1.2 pom.xml <?xml version"1.0" encoding"UTF-8"?> <project xmlns"http://maven.apache.org/POM/4.0.0" xmlns:xsi"http://www.w3.org/2001/XMLSchema-instance"xsi:schemaLocation"htt…

ThingsGateway:一款基于.NET8开源的跨平台高性能边缘采集网关

前言 今天大姚给大家分享一款基于.NET8开源的跨平台高性能边缘采集网关&#xff0c;提供底层PLC通讯库&#xff0c;通讯调试软件等&#xff0c;单机采集数据点位可达百万&#xff1a;ThingsGateway。 项目技术栈 后端技术栈&#xff1a;支持.NET 6/7/8&#xff0c;Sqlsugar&am…

爬虫使用优质代理:确保高效稳定的数据采集之道

爬虫使用优质代理的最佳实践 在进行网络爬虫时&#xff0c;使用优质代理就像是为你的爬虫装上了强劲的发动机&#xff0c;能够大幅提升数据抓取的效率和成功率。然而&#xff0c;选择和使用优质代理并非易事&#xff0c;今天我们就来探讨如何在爬虫中有效使用优质代理。 1. 什…

vue3组件封装系列-表格及分页-第二弹

第二弹来了&#xff0c;不知道有多少人是看过我的第一篇文章的&#xff0c;今天本来是没想更新的&#xff0c;但是现在项目正在验收期准备上线&#xff0c;闲着还不如来发发文。虽然这两天可能会高产&#xff0c;下一次高产就不知道是什么时候了。话不多说&#xff0c;先上图。…

OpenGuass under Ubuntu_22.04 install tutorial

今天开始短学期课程&#xff1a;数据库课程设计。今天9点左右在SL1108开课&#xff0c;听陈老师讲授了本次短学期课程的要求以及任务安排&#xff0c;随后讲解了国产数据库的三层架构的逻辑。配置了大半天才弄好&#xff0c;放一张成功的图片&#xff0c;下面开始记录成功的步骤…

数据融合的超速引擎——SeaTunnel

概览 SeaTunnel是一个由Apache软件基金会孵化的数据集成工具&#xff0c;专为应对大规模数据的快速处理而设计。它以高效的数据处理能力和简洁的架构&#xff0c;帮助企业在数据仓库构建、实时数据处理和数据迁移等场景下&#xff0c;实现数据流的无缝整合。SeaTunnel的设计理…

LDO工作原理与仿真

LDO工作原理与仿真 目录 LDO工作原理与仿真一、LDO内部电路组成1. 基准电压源&#xff08;Reference Voltage Source&#xff09;2. 误差放大器&#xff08;Error Amplifier&#xff09;3. 功率调整元件&#xff08;Power Adjustment Element&#xff09;4. 分压取样电路&#…

用于不平衡分类的 Bagging 和随机森林

用于不平衡分类的 Bagging 和随机森林 Bagging 是一种集成算法&#xff0c;它在训练数据集的不同子集上拟合多个模型&#xff0c;然后结合所有模型的预测。 [随机森林]是 bagging 的扩展&#xff0c;它也会随机选择每个数据样本中使用的特征子集。bagging 和随机森林都已被证…

【Word与WPS如何冻结首行首列及窗口】

1.Word如何冻结首行首列及窗口 microsoft word 中锁定表头是一项实用的功能&#xff0c;可让您在滚动文档时保持表头可见。这在处理大型文档或包含大量数据的表格时非常有用。php小编柚子将为您详细介绍 word 锁定表头位置的方法&#xff0c;帮助您轻松掌握这项实用技巧。 1.…

实体书商城小程序的设计

管理员账户功能包括&#xff1a;系统首页&#xff0c;个人中心&#xff0c;用户管理&#xff0c;小说分类管理&#xff0c;小说信息管理&#xff0c;订单管理&#xff0c;系统管理 微信端账号功能包括&#xff1a;系统首页&#xff0c;小说信息&#xff0c;小说资讯&#xff0…

Qt_两种创建组件的方式-通过图形化-通过代码

文章目录 一、通过图形化的方式&#xff0c;在界面上创建一个控件&#xff0c;显示hello world1.打开UI设计界⾯2.拖拽控件⾄ ui 界⾯窗⼝并修改内容3.构建并运行 二、通过代码的方式&#xff0c;通过编写代码&#xff0c;在界面上创建控件&#xff0c;显示hello world在Widget…

手撕python之基本数据类型以及变量

​​​​​​1.基础概念 python就是将不同的数据划分成了不同的类型 就像我们生活中的数据有数字、字符等数据一样 小知识点&#xff1a; 注释&#xff1a;# 全体注释&#xff1a;AltF3 取消注释&#xff1a;AltF4 2.数值类型 数值类型概括 数值类型分为三种&#xff…

Cesium 展示——动态洪水淹没效果

文章目录 需求分析1. 引入插件2. 定义变量3. 开始绘制3.1 绘制点3.2 绘制线3.3 绘制面3.4 开始分析(第一种)3.5 开始分析(第二种)3.6 方法调用4. 整体代码其他需求 从低处到高处实现洪水淹没效果 分析 本篇文章对方法进行单独抽离,因此支持拿来即用,注意传参就可 1. …

宠物系统小程序的设计

管理员账户功能包括&#xff1a;系统首页&#xff0c;个人中心&#xff0c;用户管理&#xff0c;商品信息管理&#xff0c;店主管理&#xff0c;猫狗查询管理&#xff0c;猫狗宠物社区&#xff0c;管理员管理&#xff0c;系统管理 微信端账号功能包括&#xff1a;系统首页&…