• 实现简易负载式均衡在线判题系统


    前言

    这个项目的大概:
    分为两部分:

    • 主server(负载均衡的选择多个从server来处理业务逻辑)
    • 多个从server

    说明:

    • 这个项目主要是想把之前学的知识贯通起来,主业务是oj判题
    • 本人水平有限,本来是想自己实现个httpserver,用在上面说的两部分客户端上,但httpserver的Reactor模型构建部分一直出毛病(我只有将构建部分的写事件就绪改为手动调用event的写回调(半个reactor,只监听读就绪)),并且在高并发情况少数情况会出现段错误,再加上这里实现的httpserver只有cgi来处理非网页请求, 使用cgi来进行负载均衡不方便(restful+rpc等方式来实现较为复杂,也还暂时不熟悉这些技术),所以第一部分的主server使用了cpphttplib库(restful回调机制方便,所有请求共享一个Controller)选择的多个编译运行server使用的是自己写的httpserver
    • 这个项目把前端到后端打通了,相对以前有了一个比较清晰的概览,但也发现没有学的东西又变多了很多

    还没有学习muduo库,这个项目就先这样,有点拉,后面学完后再重新构建一遍httpserver,这篇博客作为记录,方便之后重新构建

    1)HTTP Server

    单例TcpServer

    Tcpserver.hpp

    • 固定化写法,只是改为单例
    • 端口,监听套接字
    • 初始化->创建套接字,绑定监听
    • 复习一遍

    提供的函数接口:

    #include  //htos htol...
    #include 
    
    #include 
    #include  //socket
    
    #include 
    #include  //addr family
    
    #include 
    #include //non-block
    
    void Socket();//获取listen sock
    void Bind();//绑定sockaddr_in
    void Listen();//监听
    int Sock();//返回sock
    void SetNonBlock(ssize_t sock);//设置sock为非阻塞
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    注意

    • 获取listen sock时要setsockopt(_listen_sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); // socket地址复用(server挂了立即重启)
    • 单例模式的懒汉模式,在上一个tcmalloc learn项目中也用的比较多,这里不再赘述

    Log简易日志

    未实现异步日志 (后续重新构建再实现),这里先简单实现打印日志
    日志等级:

    enum LOGLEVEL
    {
    	LOG_LEVEL_ERROR=1,     // error
    	LOG_LEVEL_WARNING,   // warning
    	LOG_LEVEL_FATAL,     // fatal
    	LOG_LEVEL_INFO,      // info	
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    有两种方式

    • LOG(LEVEL, 字符串message);
    #define LOG(level, message) Log(#level, message, __FILE__, __LINE__);
    void Log(std::string level, std::string message, std::string file_name, int line)
    {
    	std::cout <<GetColor(level)<< "[" << level << "] " 
    			  <<LIGHT_CYAN<< " ["<<TimeStampToTime()<<"] " 
    		   	  <<GetColor(level)<< " [" << message << "] " 
    	          <<LIGHT_CYAN<< " [In file: " << file_name << "] " 
    	          << " [Line: " << line << "]" <<NONE<< std::endl;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • LOG(LEVEL)<<字符串message;(返回ostream类型的cout,像cout一样使用)
    #define LOG(level) Log(#level, __FILE__, __LINE__)
    inline std::ostream &Log(const std::string &level, const std::string &file_name, int line)
    {
       //cout内部包含缓冲区
       std::cerr<<GetColor(level)<<"["<<level<<"]"
                <<LIGHT_CYAN<<" ["<<TimeStampToTime()<<"]"                \
                <<" [In file: "<<file_name<<"]"
                <<" [Line: "<<line<<"]"
                <<GetColor(level)<<" Message: "
                <<NONE;//注意不要endl刷新
       return std::cerr;//这里不是cout原因:上层httpserver的cgi部分dup了标准输出
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    支持不同LEVEL等级不同颜色高亮 和 时间戳转北京时间:
    在这里插入图片描述

    协议定制(Protocol概览)

    只实现了HTTP1.0,GET/POST请求,可拓展其他不同的请求方式,未实现Cookie Session,基于短连接

    Request Response

    协议定制部分
    HttpRequest请求类的成员设计

    //拆分body
           std::string requestLine;//请求行
           std::vector<std::string> requestHeader;//请求头
           std::string blank;//空行
           std::string requestBody;//请求正文
    //解析后结果
           //请求行
           //解析完毕之后的结果
           std::string method;//请求方法
           std::string uri; //请求资源(可能包括路径,参数)
           std::string version;//协议即版本
           //请求头
           std::unordered_map<std::string, std::string> headerMap;
           //请求正文
           std::string body;
    //继续细化拆分        
           int ContentLength;
           std::string path;//uri中的路径
           std::string suffix;//获得的后缀
           std::string query_string;//uri中的参数(?后面的就是参数语句)
    
           bool cgi;//是否进行cgi处理
           int size;
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23

    HttpResponed响应类成员设计

    //构建需要的部分
           std::string status_line;//状态行
           std::vector<std::string> response_header;//响应头
           std::string blank;//空行
           std::string response_body;//响应正文
    //细化部分
           int statusCode;
           int fd;//fd用于读取文件,EndPoint的_sock用于接收文件
           int size;//request目标文件的大小,也是返回文件大小
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    通过文件后缀判定Content-Type(可拓展):

    static std::string Suffix2Desc(const std::string &suffix)
    {
       static std::unordered_map<std::string, std::string> suffix2desc = {
           {".html", "text/html"},
           {".css", "text/css"},
           {".js", "application/javascript"},
           {".jpg", "application/x-jpg"},
           {".xml", "application/xml"},
           {".json", "application/json"}
           //...可拓展
       };
       auto iter = suffix2desc.find(suffix);
       if(iter != suffix2desc.end())
           return iter->second;
       return "text/html";
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    通过返回码返回描述(可拓展):

    static std::string Code2Desc(int code)
    {
       std::string desc;
       switch(code){
           case 200:
               desc = "OK";
               break;
           case 404:
               desc = "Not Found";
               break;
         //case 500:...       
           default:
               break;
       }
       return desc;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    主部分对端EndPoint

    对端EndPoint(提供接口构建请求,解析请求 构建响应 返回响应)
    成员设计:

    private:
    	int sock;
    	HttpRequest http_request;
    	HttpResponse http_response;
    	bool stop;//标记读取(写入)错误
    	ns_reactor::Event *event;//对应的事件,见后面reactor部分
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    接口设计(暴露给外部四个接口:Recv Parse Build Send):

    private:
    	bool RecvHttpRequestLine();//接收请求行
    	bool RecvHttpRequestHeader();//接收头部
    	void ParseHttpRequestLine();//解析请求行
    	void ParseHttpRequestHeader();//解析头部
    	bool HasBody();//判断是否有body(GET无,POST有)
    	bool RecvHttpRequestBody()//接收body
    	int ProcessCgi();//进行CGI处理
    	int ProcessNonCgi();//进行非CGI处理
    	void BuildHttpResponseHelper();//构建response的帮助函数
    	void ErrorHandler(std::string page);//根据page构建response自定义不同的返回页面
    	void OkHandler();//进行正常返回构建
    public:
    	void RecvHttpRequest();//调用RecvHttpRequestLine(),RecvHttpRequestHeader()
    	void ParseHttpRequest();//调用ParseHttpRequestLine(),ParseHttpRequestHeader() RecvHttpRequestBody()
    	void BuildHttpResponse();//各种处理逻辑的汇接点,构建出响应HttpResponse
    	void SendHttpResponse();//从私有的http_response读取构建好的数据进行开始send给browser
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    Entrance入口函数

    调用对端逻辑

    EndPoint *ep = new EndPoint(sock, ev);
    ep->RecvHttpRequest();
    if(!ep->IsStop()){ //如果stop为true了后面的逻辑就没必要执行了
       LOG(LOG_LEVEL_INFO, "Start Building&&Sending");
       ep->BuildHttpResponse();
       ep->SendHttpResponse();
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    协议定制(Protocol细节)

    Util工具类

    工具函数:按行读取,字符串切割

    static int ReadLine(ns_reactor::Event* event, std::string &out)//按行读取
    static bool CutString(const std::string &target, std::string &sub1_out, 
    						std::string &sub2_out, std::string sep)//按sep将target字符串切割为两部分
    
    • 1
    • 2
    • 3

    Recv,Parse

    HTTP报文结构:

    • 请求(每一行都以一个换行符结束:(\r\n或\r或\n)):
      在这里插入图片描述
    • 响应
      在这里插入图片描述

    Recv:

    • RecvHttpRequestLine(),按行读取,Readline返回小于0 读取出错设置标志位stop=true
    • RecvHttpRequestHeader(),按行读取,Readline返回小于0 读取出错设置标志位stop=true,同时将空行读取
    • RecvHttpRequestBody(),HasBody()判断是否有body(POST), 使用recv系统函数接收,出错设置标志位stop=true

    Parse:

    • ParseHttpRequestLine(),使用stringstream按空格读取解析前字符串,写入目标,注意:method统一使用transform转大写
    • ParseHttpRequestHeader(),使用Util类CutString,按": "分割,将所有请求头以key-value存储到unordered_map中

    先了解CGI

    见CGI实现部分:CGI机制及实现

    Build Send

    Build:

    • BuildHttpResponse(), 主build逻辑:用程序框图表示:

    Send:

    • 发送响应行
    • 发送响应头
    • 对于非CGI:获得请求的文件对应的fd,使用sendfile零拷贝发送:关于0拷贝,参考:sendfile—Linux中的"零拷贝"
    • 对于CGI,从http_response读取出body,再send

    线程池(阻塞队列

    基于阻塞队列的线程池


    设计回调:

    • 任务处理(将Entrance类改为Callback):
    class Task{
    private:
       int sock;
       ns_reactor::Event* event;//见Reactor部分
       CallBack handler; //设置回调
    public:
       Task()
       {}
       Task(int _sock,ns_reactor::Event* ev):sock(_sock),event(ev)
       {}
       //处理任务
       void ProcessOn()
       {
           handler(sock,event);
       }
       ~Task()
       {}
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    在线程池逻辑中,ThreadRoutine在PopTask后会调用ProcessOn(),进行任务处理,Callback类重载了operator(), 调用handler回调,进行处理

    主函数Init,Loop

    HttpServer

    • InitHttpserver
      注意忽略写入时错误(管道单向写 对端关闭)signal(SIGPIPE, SIG_IGN);
      参考:signal(SIGPIPE, SIG_IGN)
    • 进入Loop
    std::shared_ptr<HttpServer> http_server(new HttpServer(port));
    http_server->InitServer();
    http_server->Loop();
    
    • 1
    • 2
    • 3

    Reactor多线程

    之前写过简单Reactor单线程tcp通信server,逻辑参考:Linux----Reactor
    这里基本上是复用了上面说的Reactor,加了个基于阻塞队列的线程池想实现Reactor多线程(阻塞队列:容量确定,便于定制策略)
    将Loop主函数改为Reactor逻辑:

    • 创建Reactor
    • 创建监听Sock
    • 创建监听事件
    • 将Acceptor回调注册进监听事件
    • 监听事件注册到Reactor对象中
    • 创建一个struct epoll_event类型的数组
    • 进入事件派发逻辑循环, 服务器启动(可以设置epollwait的timeout)

    Dispatcher派发逻辑:

    • epoll_wait(R->epfd_, epevs, NUM, -1);阻塞等待就绪事件
      循环就绪事件个数次,进行事件的读/写就绪回调(是监听事件读就绪就进行Accepor逻辑)

    Httpserver总体流程图


    bug

    • 猜测这里有多线程问题(打印日志出现乱序)
    • 也可能有读写缓冲区问题打印乱码?
    • 段错误:SigmentFault(core dump)

    读完muduo库再改

    其他拓展方向

    • 有限自动机http协议解析
    • 异步日志
    • 主从reactor
    • 内存池
    • 其他线程池
    • 定时器

    2)Load Balance OJ

    使用到的第三方库:

    准备:工具类Util

    解释:CommonUtil:都可以使用,CompileUtil:为编译服务,RunUtil:为运行服务,CompileRunUtil:编译运行
    都可以从名字知道函数用途

    class CommonUtil{
    	static void SuffixAdd(std::string& fileName, const std::string& suffix);
    	static void StrCut(std::string &line, std::vector<std::string> *target, const std::string sep);
    	static bool FileExists(const std::string &file);
    	static bool Write2File(const std::string &in, const std::string &Src);
    	static bool ReadFromFile(std::string *out, const std::string &file, bool lineBreak = false);
    }
    class CompileUtil{
    	static std::string File2Src(const std::string& fileName);
    	static std::string File2Exe(const std::string & fileName);
    	static std::string File2Error(const std::string & fileName);
    }
    class RunUtil{
    	static std::string File2Stdin(const std::string & fileName);
    	static std::string File2Stdout(const std::string & fileName)
    	static std::string File2Stderr(const std::string & fileName);
    	static void SetProcLimit(int cpuLimit, int memLimit);
    }
    class CompileRunUtil{
    	static std::string UniqueFileName();
    	static void RemoveTmpFile(const std::string &fileName);
    	static std::string Status2Desc(int status, const std::string &fileName);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23

    编译服务

    总体流程图:

    编译

    编译部分主要在tmp目录下形成三个文件

    • 源文件source: ./tmp/fileName.cc
    • g++编译出错error : ./tmp/fileName.stderr
    • 可执行文件exe : ./tmp/fileName.exe

    编译部分只有一个函数:static bool Compile(std::string &fileName)

    • fileName:由毫秒级时间戳+atomic原子自增结合形成的唯一文件名(在Util头文件中统一实现)

    主逻辑:

    • 创建子进程:打开./tmp/fileName.stderr,将stderr也就是2 重定向到open的fd(errorFd),execlp程序替换调用g++,进行对源文件的编译生成./tmp/fileName.exe,编译失败形成./tmp/fileName.stderr文件,里面存储编译失败信息
    • fork出错:return false
    • 父进程:FileExists判定 ./tmp/fileName.exe文件是否存在,返回false/true

    运行

    运行结果是否正确是由测试用例决定的,这里不考虑,这里仅考虑

    • 运行成功
    • 程序崩溃

    运行部分主要在tmp目录下形成三个文件 :

    • 用于存储标准输入: ./tmp/fileName.stdin
    • 用于存储标准输出: ./tmp/fileName.stdout
    • 用于存储标准错误: ./tmp/fileName.stderr

    Runner内部也只有一个函数:static int Run(std::string& fileName, int cpuLimit, int memLimit)

    • fileName:由毫秒级时间戳+atomic原子自增结合形成的唯一文件名(在Util头文件中统一实现)
    • cpuLimit:控制程序运行的时间复杂度(系统函数setrlimit在Util头文件的SetProcLimit工具函数中调用)
    • memLimit:控制程序运行的空间复杂度

    返回值:

    • 返回值>0: 对应错误码(程序崩溃)
    • 返回值=0: 正常退出
    • 返回值<0: 内部错误(open出错,fork出错…)

    主逻辑:

    • 打开.stdin .stdout .stderr文件,得到对应文件描述符
    • fork创建子进程:dup2分别替换对应的文件描述符(.stdin->0 .stdout->1 .stderr->2),
      设置程序运行限制,开始程序替换,运行 ./tmp/fileName.exe,
      程序运行得到的结果会放到.stdout文件中,得到的error信息会存放到.stderr文件中
    • 失败:
    • 父进程:返回status & 0x7F

    编译&&运行

    也只有一个函数:static void Start(const std::string& inJson, std::string *outJson)

    • inJson:获取的Json串,反序列化获取内容
    • outJson:输入输出型参数,返回序列化好的内容

    设计逻辑:

    • int status 统一获取所有逻辑的返回值,通过Util头文件中的Status2Desc获得描述message
      注意:所有非信号终止错误设为负值,方便Run返回值的信号值判定
    • 错误:用户代码为空,写入文件错误,读取文件错误,编译错误,运行错误(注意返回的三种情况)
      这里的写入读取使用了C++更方便的ofstream,ifstream
    • 统一进行序列化形成outJson,
      编译&&运行出错:outJson中只需要两个键值:”status“ ”message“
      编译&&运行成功:outJson中需要四个键值:”status“ ”message“ ”stdout“ “stderr”
    • 文件统一删除(./tmp文件夹下的文件全部删除)

    测试:
    inJson:

    {
       "code": "#include\nint main(){std::cout<<\"test\"<, 
       "input": "",
       "cpuLimit": 1, 
       "memLimit": 102400
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    outJson:

    {
      "message" : "Compile And Run Success...",
      "status" : 0,
      "stderr" : "",
      "stdout" : "test\n"
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    结合httpserver形成服务,postman json测试

    注意踩过的坑

    • 设置setrlimit虚拟内存过小,进行程序替换会导致替换的程序被11号信号终止,段错误
    • HTTPserver的cgi部分已经重定向了cout cin,进程替换Compile_Run的Log信息只能用cerr打印

    httpserver的cgi中新增一个compile_run,形成可执行文件compile_run.json
    使用postman进行post请求测试:

    • 在这里插入图片描述

    负载均衡OJ服务

    采用MVC(Model, View, Controller)

    Model

    文件题库

    设计题库:

    • 编号文件->question.list(问题描述) header.cpp(基础代码) test.cpp(测试用例代码) ;设计条件编译g++ -D HEADER(ifndef仅仅为了让编译器不要报错)
      在这里插入图片描述
      questions.list存储题目标题等
      每个题目文件夹中有描述,precode,测试用例

    Model获取数据

    题目结构:

    struct Question
    {
    //5个变量描述题目
       std::string num;//题号
       std::string title;//题目标题
       std::string difficulty;//题目难度
       std::string desc;//题目描述
       int time;//事件复杂度要求(s)
       int space;//空间复杂度要求(kb)
    //拼接下面两部分
       std::string header;//题目自带代码部分
       std::string test;//题目测试用例部分
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    接口:

    bool LoadQuestionGE(const std::string &configfileName)//从question文件加载题目信息
    bool GetOneQuestion(const std::string &num, Question *qu)//获取某一道题
    bool GetAllQuestion(std::vector<Question> *quVec);//获取所有题目
    
    • 1
    • 2
    • 3
    文件

    按行从questions.list中获取:题号,标题,难度,时间复杂度,空间复杂度

    CommonUtil::ReadFromFile(&(q.desc), path+"desc.txt", true);//获取题目描述
    CommonUtil::ReadFromFile(&(q.header), path+"header.cc", true);//获取precode部分
    CommonUtil::ReadFromFile(&(q.test), path+"test.cc", true);//获取测试用例
    
    • 1
    • 2
    • 3

    同时维护一个std::unordered_map _num2Qu;题号到题目的映射

    View

    渲染网页,关于ctemplate使用略

    • 渲染题库 void RenderAllQuestionHtml(const vector &allQuestion, string *html)
    • 渲染题目 void RenderOneQuestionHtml(Question q, string *html)

    Controller(负载均衡选择)

    服务器主机类:

    class Machine{//描述主机
    private:
    	std::string ip;//选择的服务器ip
    	int port;//ip的端口
    	uint64_t load;//负载
    	Mutex mtx;//这里是自己封装的pthread_mutex互斥锁(保护负载)
    public:
    	void IncreaseLoad()// 提升主机负载
    	void DecreaseLoad();// 减少主机负载
    	void ResetLoad()//重置主机负载
    	uint64_t Load()// 获取主机负载
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    负载均衡类提供负载均衡选择方法

    class LoadBalance{//进行负载均衡选择
    private:
       std::vector<Machine> _machines;
       std::vector<int> _onlineMachines;//在线主机(int id用_machines中对应下标表示)
       std::vector<int> _offlineMachines;//离线主机
       Mutex mtx;
    public:
    	bool LoadConfig(const std::string &servicePath)//从configPath加载配置文件
    	//Round-Robin(轮询 + hash)
    	bool LoadBalanceSelect(int *id, Machine **machine)
    	//两个参数都是输出型参数,注意这里的二级指针(需要的是Machine的地址)
    	void OfflineMachine(int id)//离线主机
    	void OnlineMachine()//上线主机(当所有主机离线时一键(ctrl+\)上线所有主机)
    	void ShowMachines()//日志打印在线的主机
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    Contoller控制器:

    class Controller{
    private:
       Model _model;//获取后台数据
       View _view;//获取渲染网页
       LoadBalance _loadbalance;//负载均衡器
    public:
    	void RecoverMachine()//用于服务器主函数回调一键上线功能
    	bool AllQuestionHtml(std::string *html)//传给View获取渲染的网页
    	bool OneQuestionHtml(std::string &num, std::string *html)
    	void Judge(std::string &num, const std::string inJson, std::string *outJson)//判题获得outJson
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    逻辑:

    综合测试

    使用httplib形成服务:见代码
    关于前端:前端是网上找的模板,改了一下,提交按钮使用ajax发起POST请求
    负载均衡测试:

    • 快速点submit按钮,8082和8083,8084编译运行服务器交替处理,请求,负载基本上维持在2左右在这里插入图片描述
    • 逐步断开对端编译运行主机(服务器cpu比较垃圾,经常死机)负载加到3左右:
      在这里插入图片描述
    • 只留下一台主机,不断submit提交,短时间大量请求,负载会不断增加
      在这里插入图片描述
  • 相关阅读:
    Servlet学习(八):Session
    IDEA中debug调试模拟时显示不全(不显示null)的解决
    Python中判断两个集合是否相交的方法 - isdisjoint()
    CAN 通信-底层
    点餐小程序实战教程02用户注册
    使用BASE64实现编码和解码
    Tenable Nessus 8.15.5 (Unix, Linux, Windows) -- #1 漏洞评估解决方案
    【数据结构笔记01】数据结构之线性表的顺序表示和实现(C代码)
    解决 uniapp uni.getLocation 定位经纬度不准问题
    宁夏果蔬系统
  • 原文地址:https://blog.csdn.net/qq_41420788/article/details/127410013