返回顶部
首页 > 资讯 > 服务器 >自主Web服务器Http_Server
  • 373
分享到

自主Web服务器Http_Server

服务器http网络网络协议 2023-09-06 20:09:52 373人浏览 薄情痞子
摘要

目录 自主web服务器背景目标描述技术特点项目定位 项目实现过程创建HttpServer基础框架TcpServer.hppHttpServer.hppLog.hppProtocol.hpp

目录

自主WEB服务器

背景

Http协议被广泛使用,从移动端,pc端浏览器,http协议无疑是打开互联网应用窗口的重要协议,http在网络应用层中的地位不可撼动,是能准确区分前后台的重要协议。

目标

在对http协议的理论学习的基础上,从零开始完成web服务器开发,坐拥下三层协议,从技术到应用,让网络难点无处遁形。

描述

采用C/S模型,编写支持中小型应用的http,并结合Mysql,理解常见互联网应用行为,做完该项目,你可以从技术上 完全理解从你上网开始,到关闭浏览器的所有操作中的技术细节!

技术特点

项目定位

研发岗

项目实现过程

由于我们编写的是HTTP_SERVER,因此我们只需要编写s端,c端我们使用浏览器进行访问即可;
在这里插入图片描述

我们需要对**应用层(主要)**和传输层进行代码编写,网络层及一下,会有对应的TCP/IP协议来保证数据的交互;

下图表示短连接下,C端发起请求,S端响应请求,一来一回 之后关闭sock;

在这里插入图片描述

创建httpserver基础框架

先创建一个能接收到浏览器HTTP报文的socket框架;

TcpServer.hpp

这里将TcpServer中的socker,bind,listen进行了封装,用Init启动,同时设计了单例模式,一个HttpServer只需要一个监听listen_sock即可!

#pragma once#include #include #include #include #include #include #include #include #include #include "Log.hpp"using std::cout;using std::endl;#define BACKLOG 5enum ERR{    SOCK_ERR = 1,    BIND_ERR,    LISTEN_ERR,    USAGE};class TcpServer{private:    int port;    int listen_sock;    static TcpServer* svr;private: //单例模式TcpServer(int _port):port(_port)  //私有构造    {      }    TcpServer(const TcpServer &s) //私有拷贝构造    {    }public:    static TcpServer *getinstance(int port)//单例模式    {        static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;        if (nullptr == svr)        {            pthread_mutex_lock(&lock);            if (nullptr == svr)            {                svr = new TcpServer(port);                svr -> InitServer();//getinstance的时候就搞定了sock bind listen了;            }            pthread_mutex_unlock(&lock);        }        return svr;    }public:    void InitServer()    {        Socket();        Bind();        Listen();                 LOG(INFO, "TcpServer begin");//日志    }    void Socket()    {        listen_sock = socket(AF_INET, SOCK_STREAM, 0);        if (listen_sock < 0)        {            LOG(FATAL, "socket error");            exit(SOCK_ERR);        }        //防止bind error        int opt = 1;        setsockopt(listen_sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));    }    void Bind()    {        sockaddr_in local;        bzero(&local, sizeof(local));        local.sin_family = AF_INET;        local.sin_port = htons(port);        local.sin_addr.s_addr = INADDR_ANY;//云服务器这样绑        if (bind(listen_sock, (sockaddr *)&local, sizeof(local)) < 0)        {            LOG(FATAL, "bind error");            exit(BIND_ERR);        }    }    void Listen()    {        if (listen(listen_sock, BACKLOG) < 0)        {            LOG(FATAL, "listen error");            exit(LISTEN_ERR);        }    }    int Sock()    {        return listen_sock;    }    ~TcpServer()    {        if (listen_sock > 0)            close(listen_sock);    }};//单例TcpServer *TcpServer::svr = nullptr;

HttpServer.hpp

#pragma once#include #include #include #include "Log.hpp"#include "TcpServer.hpp"#include "Protocol.hpp"#define PORT 8080//默认端口号class HttpServer{private:    int port;    bool stop;public:    HttpServer(int _port = PORT) : port(_port), stop(false)    {    }    void InitServer()    {        // singal(SIGPIPE,SIG_IGN);    }    void Loop()//循环监听c端逻辑    {        TcpServer *tsvr = TcpServer::getinstance(port); // TcpServer里面就处理了,sock bind listen TcpServer里面就处理了        LOG(INFO, "Loop Begin");        while (!stop)        {            sockaddr_in peer;            socklen_t len = sizeof(peer);            int sock = accept(tsvr->Sock(), (sockaddr *)&peer, &len);            if (sock < 0)                continue;            LOG(INFO, "Get a new link"); //到这里 httpserver整体就能接收新连接了!            //创建handler线程,将连接的sock甩进去,再loop循环以后的c端链接            pthread_t tid;            int *psock = new int(sock);//注意局部变量的传参            pthread_create(&tid,nullptr,Entrance::HandlerRequest,psock);            pthread_detach(tid);        }    }    ~HttpServer() {}};

Log.hpp

建议的日志系统

#pragma once#include #include #include //日志处理#define INFO    #define WARNING  #define ERROR   #define FATAL   #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 << "[" << level << "] " << "[" << time(nullptr) << "] " << "[" << message << "] " << "[" << file_name << "] " << "[" << line << "] " << std::endl;}

Protocol.hpp

订制一系列的协议,用于才做http报文。构建响应等;

#pragma once#include #include #include #include using std::cout;using std::endl;class Entrance//临时方案{public:    //loop创建的线程执行任务的函数    static void *HandlerRequest(void *psock)    {        int sock = *(int *)psock;        delete (int *)psock;        char buff[4022];        int s = recv(sock, buff, 4022, 0);        buff[s-1] = '\0';        cout << "===============begin===============" << endl;        cout << buff << endl;        cout << "===============end===============" << endl;        return nullptr;    }};

运行结果
在这里插入图片描述

前三行是打印的日志信息,后面是c端浏览器访问我们server的时候发送的报文,我们将它打印出来了;

解析C端发来的HTTP报文

在这里插入图片描述

可见,报文都是一行一行的,我们需要按行读取,先来个按行读取的工具

MSG_PEEK标志位

recv(sock, &c, 1, MSG_PEEK);

我们一般是设置为0,如果设置MSG_PEEK标志位,则仅仅是把tcp缓冲区中的数据拷贝式的读取到buf中,并没有把已读取的数据从tcp缓冲区中移除,相当于peek窥探一下; 这样我们就可以处理的同时,防止破坏下个报文的报头,造成数据报文不完整了;

Util.hpp

工具类Util

#pragma once#include #include #include #include using std::string;//工具类class Util{public:    static int ReadLine(int sock, string &out) //按一行读取报文,返回长度;    {        char c = 'X';        while (c != '\n')        {            ssize_t s = recv(sock, &c, 1, 0); //(注意,有的报文以\r\n 或者 \r结尾,统一处理为\n,同时考虑数据粘包问题进行读取!)            if (s > 0)            {                if (c == '\r')                {                    recv(sock, &c, 1, MSG_PEEK); //窥探一下                    if (c == '\n')                    { //窥探成功!大胆拿走这个\n 放入c中                        recv(sock, &c, 1, 0);                    }                    else                    { //窥探失败,直接换掉这个\r                        c = '\n';                    }                }                out += c;            }            else if (s == 0)            {                return 0;            }            else            {                 return -1;            }        }        return out.size();    }};

用Entrance收到报文测试,然后调用按行读取一次,结果如下(调用一次,读取一行,即便请求行)

在这里插入图片描述

构建请求与响应类

Protocol.hpp

//请求类class HttpRequest{public:    string request_line;           //读取请求行    vector<string> request_header; //读取请求报头    string blank;                  //空行分隔符    string request_body;           //请求报文主体(可能没有)    //解析完毕之后的结果    //解析请求行三部分    string method;    string uri; // path?args    string version;    //解析请求报头    unordered_map<string, string> header_kv;    int content_length; //请求body的大小    string path;        //请求路径    string suffix;      //后缀 .html  <-> query_string: type/html    string query_string;    bool cgi; // cgi技术开关    int size; //响应的html文件的size大小public:    HttpRequest() : content_length(0), cgi(false) {}    ~HttpRequest() {}};//响应类class HttpResponse{public:    string status_line;                  //状态行    vector<std::string> response_header; //响应报头    string blank;                        //空行分隔符    string response_body;                //响应报文主体(html)    int status_code;    int fd;public:    HttpResponse() : blank(LINE_END), status_code(OK), fd(-1) {}    ~HttpResponse() {}};

上述部分成员后续解析报文详细讲解;

读取,解析请求构建响应

读取请求

读取请求的目的为将整个报文按照一定的格式读入请求类中;

  • 请求行放入string request_line
  • 请求报头存入vector request_header;
  • 空行分隔符放入string blank
  • 请求正文(如果有)放入request_body;
//读取请求,分析请求,构建响应// IO通信class EndPoint{private:    int sock;    HttpRequest http_request;    HttpResponse http_response;    bool stop;public:    EndPoint(int _sock) : sock(_sock), stop(false)    {    }public:    bool RecvHttpRequestLine() //读取请求行    {        auto &line = http_request.request_line;        if (Util::ReadLine(sock, line) <= 0)        {            stop = true;        }        else        {            line.resize(line.size() - 1); //去掉多余的'\n',塞入日志;            LOG(INFO, http_request.request_line);        }        // cout << "RecvHttpRequestLine: " << stop << endl;        return stop;    }    bool RecvHttpRequestHeader() //读取请求报头 去掉多余的\n    {        auto &v = http_request.request_header;        while (1) //注意 vector[0]没有值的时候只能push_back进去噢  v[0]=? 会段错误 越界;        {            string line;            if (Util::ReadLine(sock, line) <= 0)            {                stop = true;                break;            }            if (line == "\n")            {                http_request.blank = line; //空行                break;            }            //正常 k:v \n            line.resize(line.size() - 1); //去\n            http_request.request_header.push_back(line);            LOG(INFO, line);        }        return stop;    }};bool IsNeedRecvHttpRequestBody()//判断需不需要读 POST方法+存在contentlength,就要读取body了     {        auto& method = http_request.method;        auto& mp = http_request.header_kv;        if(method == "POST"){            if(mp.find("Content-Lenght")!=mp.end()){                http_request.size = atoi(mp["Content-Lenght"].c_str());//记录一下body的size                return true;            }            return true;        }     }     bool RecvHttpRequestBody()     {        if(IsNeedRecvHttpRequestBody()){            int len = http_request.size;//这里不能&,不然下面循环 原来的size就减没了,为啥这么精确 -->防止粘包            auto body = http_request.request_body;            for(int i = 0;i<len;i++){                char c;                int s = recv(sock,c,1,0);                if(s>0){                    body+=c;                }                else{                    stop = true;                    break;                }            }           return stop;             }     } bool IsNeedRecvHttpRequestBody() //判断需不需要读 POST方法+存在contentlength,就要读取body了    {        auto &method = http_request.method;        auto &mp = http_request.header_kv;        if (method == "POST")        {            if (mp.find("Content-Length") != mp.end())            {                http_request.size = atoi(mp["Content-Length"].c_str()); //记录一下body的size                return true;            }            return false;        }        return false;    }    bool RecvHttpRequestBody()    {        if (IsNeedRecvHttpRequestBody())        {            int len = http_request.size; //这里不能&,不然下面循环 原来的size就减没了,为啥这么精确 -->防止粘包            auto body = http_request.request_body;            for (int i = 0; i < len; i++)            {                char c;                int s = recv(sock, &c, 1, 0); //流式读取                if (s > 0)                {                    body += c;                }                else                {                    stop = true;                    break;                }            }            cout << endl;            cout << body << endl;            return stop;        }    }

在这里插入图片描述

注意正文的读取需要配合后面的parse先解析拿出参数,再判断有没有正文读取;

解析请求

解析请求的过程为将读取的request报文的对应属性和内容存入特定的请求类中;用于后续构建响应直接对照构建;

  • 请求行的三个属性提取出来分别放入method,uri,version
  • 请求报头数组中的一个个k:v分别提出来进行unordered_map的映射{k,v},方便后续直接查询

Util.hpp添加一个工具函数

  static bool CutString(const std::string &target, std::string &sub1_out, std::string &sub2_out, std::string sep)     {        size_t pos = target.find(sep);        if(pos!=string::npos){                        sub1_out = target.substr(0,pos);            sub2_out = target.substr(pos+sep.size());//": "header以这个分割的,那就得+2!,注意细节,正常的"?"来分割就加1,实现了通用!!            return true;        }        return false;     }

stringstream类用法

 void ParseHttpRequestLine() //解析请求行,入method,uri,version    {        // GET / HTTP/1.1 三部分用" "分隔        stringstream ss(http_request.request_line);        ss >> http_request.method >> http_request.uri >> http_request.version;        auto &method = http_request.method;        std::transfORM(method.begin(), method.end(), method.begin(), ::toupper); //将请求方法大小写同一;        // cout<    }    void ParseHttpRequestHeader() //解析请求报头,入header_kv;    {        auto &mp = http_request.header_kv;        auto &v = http_request.request_header;        for (auto &e : v)        {            //"k:v"->mp(k,v)            string k, v;            Util::CutString(e, k, v, ":");            mp[k] = v;        }        // for(auto&e:mp){        //     cout<        // }    }

在这里插入图片描述

构建响应

响应格式

在这里插入图片描述

stat系统函数

#include  #include #include int stat(const char *path, struct stat *buf);//linux获取文件信息的系统接口//参数1:文件路径//参数2:stat st;&st    将特定目录下文件的信息保存在st中;//返回值:成功返回0,失败返回-1;

在这里插入图片描述

其中st_mode有:

在这里插入图片描述

static string Code2Desc(int code)//状态码->状态描述{    std::string desc;    switch (code)    {    case 200:        desc = "OK";        break;    case 404:        desc = "Not Found";        break;    default:        break;    }    return desc;}static std::string Suffix2Desc(const std::string &suffix)//后缀->Content-Type{    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"},    };    auto iter = suffix2desc.find(suffix);    if (iter != suffix2desc.end())    {        return iter->second;    }    return "text/html"; //默认返回html的type}void BuildHttpResponse()    {        struct stat st;        int size;        ssize_t rfound;        string _path; // temp        auto &status_code = http_response.status_code;        auto &method = http_request.method;        if (method != "GET" && method != "POST")        {            //非法method            status_code = BAD_REQUEST;            LOG(WARNING, "method error!");            Goto END;        }        //构建请求路径path 和 请求文件大小size;        if (method == "GET")        {            if (http_request.uri.find("?") != string::npos)               // get 带参// 引入cgi            {     // GET:  path? content=...参数                Util::CutString(http_request.uri, http_request.path, http_request.query_string, "?"); //构建path路径                http_request.cgi = true;      //有参数 引入cgi            }            else            {                http_request.path = http_request.uri;            }        }        else if (method == "POST") // cgi        {            http_request.path = http_request.uri;            http_request.cgi = true;        }        else        {            // DO Noting        }        //请求路径 我们上层得套wwwroot,index.html等默认        _path = http_request.path;        http_request.path = WEB_ROOT;        http_request.path += _path;        //如果路径末尾为'/' 意味着是个目录,我们需要套上index.html        if (http_request.path.find('/') == http_request.path.size() - 1)        {            http_request.path += HOME_PAGE;        }        //判断文件存在?存在属性保存进st        if (stat(http_request.path.c_str(), &st) == 0)        {            if (S_ISDIR(st.st_mode))            {                //是个目录不是html文件,特殊处理到默认                http_request.path += '/';                http_request.path += HOME_PAGE;                stat(http_request.path.c_str(), &st); //更新path文件的信息            }            if ((st.st_mode & S_IXUSR) || (st.st_mode & S_IXGRP) || (st.st_mode & S_IXOTH))            {                //是个可执行程序!不是html                http_request.cgi = true; //特殊处理cgi            }            size = st.st_size;        }        else        { //说明资源是不存在的            LOG(WARNING, http_request.path + "Not Found!");            status_code = NOT_FOUND;            goto END;        }        //构建suffix后缀        rfound = http_request.path.rfind("."); //构建suffix:<-->type映射;        if (rfound == string::npos)        { //没有.后缀 //suffix 默认 .html            http_request.suffix = ".html";        }        else        {            http_request.suffix = http_request.path.substr(rfound); //.xxx 文件类型        }        // cgi处理还是Noncgi处理?        if (http_request.cgi)        {            // status_code = ProcessCgi(); //执行目标程序,拿到结果:http_response.response_body;        }        else        {            // 1. 目标网页一定是存在的            // 2. 返回并不是单单返回网页,而是要构建HTTP响应!全套!            status_code = ProcessNonCgi(size); //简单的网页返回,返回静态网页,只需要打开即可        }    END:        return;        BuildHttpResponseHelper(); //状态行填充了,响应报头也有了, 空行也有了,正文有了    }int ProcessNonCgi(int size)//非cgi的静态网页响应    {        //这里一定有目的path了,构建response        http_response.fd = open(http_request.path.c_str(), O_RDONLY);        if (http_response.fd >= 0)        {            //构建状态行            http_response.status_line += HTTP_VERSION; //版本号            http_response.status_line += " ";            http_response.status_line += std::to_string(http_response.status_code); //状态码            http_response.status_line += " ";            http_response.status_line += Code2Desc(http_response.status_code); //状态码描述            http_response.status_line += LINE_END;            http_response.size = size;            //构建报头            string header_line = "Content-Type: ";            header_line += Suffix2Desc(http_request.suffix);            header_line += LINE_END;            http_response.response_header.push_back(header_line);            header_line = "Content-Length: ";            header_line += std::to_string(size);            header_line += LINE_END;            http_response.response_header.push_back(header_line);            //构建空行分隔符            http_response.blank = LINE_END;                        //body不需要构建,是个html网页源码,不需要拉到用户层,等会直接sendfile出去就行,高效            return OK;        }        return 404;    }

发送响应

sendfile系统函数

sendfile函数在两个文件描述符之间传递数据(完全在内核中操作),从而避免了内核缓冲区和用户缓冲区之间的数据拷贝,效率很高,被称为零拷贝。函数定义为:

#includessize_t senfile(int out_fd,int in_fd,off_t* offset,size_t count);//in_fd参数是待读出内容的文件描述符,//out_fd参数是待写入内容的文件描述符。//offset参数指定从读入文件流的哪个位置开始读,如果为空,则使用读入文件流默认的起始位置。//count参数指定文件描述符in_fd和out_fd之间传输的字节数。

在这里插入图片描述

 void SendHttpResponset()    {        //发状态行        send(sock, http_response.status_line.c_str(), http_response.status_line.size(), 0);        //发报头        for (auto iter : http_response.response_header)        {            send(sock, iter.c_str(), iter.size(), 0);        }        //发\n        send(sock, "\n", 1, 0);        //发body        sendfile(sock, http_response.fd, nullptr, http_response.size);        close(http_response.fd);    }

运行效果:

在这里插入图片描述

上面是我们调用非Cgi技术返回本地静态网页的过程,这显然是不够的,有时候c端请求会带参数需要我们server端处理,这时候就需要引入Cgi技术了;

Cgi技术

简介CGI(Common Gateway Interface)公共网关接口,是外部扩展应用程序与 Web 服务器交互的一个标准接口。它可以使外部程序处理www上客户端送来的表单数据并对此作出反应,通过某些特定的方式处理数据返回给Web服务器进而返回给c端;

在这里插入图片描述

虽然我们是创建新线程执行每个c端请求的,但由于我们http_server的进程只有一个,想要到特定位置执行cgi程序,此处不能直接exec替换掉当前进程,否侧httpserver直接没了;

那么就需要创建子进程进行一系列替换操作了;为了实现数据的交互,我们需要同时引入进程间通信,由于是父子之间,那就匿名管道!(因为管道是单向通信,我们要双向通信,所以搞两个管道)

在这里插入图片描述

可我们打开两个管道后,父子进程可以看到没错,当子进程进行exec程序替换(只替换代码和数据)之后,这两个匿名管道是数据没了管道还是存在的,(虽然还是存在着的,但是替换的程序看不到的),因为相当于一个全新的进程开始运行,他的文件描述符数组只有初始的0,1,2号fd;3,4号这两个打开的管道被藏起来了,那怎么处理呢?
在这里插入图片描述

采用如下设计(一种约定):

我们采用dup2把0,1号标准fd重定向成当前的两个管道3,4;之后再exec替换,exec替换的程序里里是有0,1标准输入输出的,但是他其实已经被替换成两个管道了,用0,1就可以完成server与cgi.exe的交互了;

cgi程序获取数据

  • 当c端GET方法发送数据时,一般比较短,我们直接利用环境变量导入可以让cgi程序拿到;
  • 当c端POST方法发送数据时,我们直接通过管道写入cgi;
  • 当然至于是GET还是POST方法,我们需要导入一个METHOD方法环境变量,让cgi程序可以识别
int ProcessCgi()    {        auto &bin = http_request.path;                 // cgi.exe的位置,子进程exec它        auto &method = http_request.method;            // GET OR POST        auto &body = http_request.request_body;        // POST 多 直接write到child        auto &querystring = http_request.query_string; // GET 少 利用环境变量        string query_string_env;        string method_env;        //站在父进程的角度创建匿名管道;        int input[2];        int output[2];        if (pipe(input) < 0)        {            LOG(ERROR, "pipe input error!");            return 404;        }        if (pipe(output) < 0)        {            LOG(ERROR, "pipe output error!");            return 404;        }        //创建子进程,进行cgi        pid_t pid = fork();        if (pid == 0)        { // child            close(input[0]);            close(output[1]);            //在子进程角度            // input[0]:读入->fd:0<->output[0];            // input[1]:写出->fd:1<->input[1];            dup2(output[0], 0);            dup2(input[1], 1);            //让替换的cgi程序知道GET还是POST方法,对应选择接收数据的方式            method_env = "METHOD=";            method_env += method;            putenv((char *)method_env.c_str());            if (method == "GET")            {                query_string_env = "QUERY_STRING=";                query_string_env += querystring;                putenv((char *)query_string_env.c_str());            }            // exec* bin            // dup2替换fd之后,execl替换程序直接对0,1进行读取与写入,实际上就是与http_server的读取和写入            execl(bin.c_str(), bin.c_str(), nullptr);            exit(1); // execl失败        }        else if (pid < 0)        { // error;            return 404;            LOG(ERROR, "fork error!");        }        else        {                     // parent            close(input[1]);  //父从cgi读,关掉写            close(output[0]); //夫给cgi写,关掉读   //post方法传的参数多,父进程直接cgi给exec程序            if (method == "POST")            {                const char *start = body.c_str();                int total = 0;                int size = 0;                while ((size = write(output[1], start + total, body.size() - total)) > 0)                {                    total += size;                }            }            waitpid(pid, nullptr, 0);            //fd资源释放            close(input[0]);            close(output[1]);        }        return OK;    }

test_cgi.cc

#include #include #include using namespace std;int main(){    cerr << "========================cgi begin===================" << endl; //用cerr测试,亦谓cout已经被我们替换成管道了!    string method = getenv("METHOD");    cerr << "METHOD = " << method << endl;    string query_string;    if (method == "GET")    {        query_string = getenv("QUERY_STRING");        cerr << "GET DeBug query_string = " << query_string << endl;    }    else if (method == "POST")    {        cerr << "Content-length = " << getenv("CONTENT_LENGTH") << endl;        int count_length = atoi(getenv("CONTENT_LENGTH"));        while (count_length--)        {            char c;            read(0, &c, 1);            query_string += c;        }        cerr << "POST DeBug query_string = " << query_string << endl;    }    else    {    }        //数据处理...        cerr << "========================cgi end===================" << endl;    return 0;}

Makefile的封装

bin=servercgi=test_cgicc=g++LD_FLAGS=-std=c++11 -lpthreadcurr=$(shell pwd)src=main.ccALL:$(bin) $(cgi).PHONY:ALL$(bin):$(src)$(cc) -o $@ $^ $(LD_FLAGS)$(cgi):cgi/test_cgi.cc$(cc) -o $@ $^.PHONY:cleanclean:rm -f $(bin) $(cgi)rm -rf output.PHONY:output  #发布软件 make outoutput:mkdir -p outputcp $(bin) outputcp -rf wwwroot outputcp $(cgi) output/wwwroot

运行结果:

GET:

在这里插入图片描述

在这里插入图片描述

POST:

在这里插入图片描述

在这里插入图片描述

cgi程序处理并返回数据

cgi程序对读入的数据进行处理;在返回给http_server,进而返回给sock(c端链接)

test_cgi.cc

#include #include #include using namespace std;bool GetQueryString(string &query_string){    bool result = false;    string method = getenv("METHOD");    cerr << "METHOD = " << method << endl;    if (method == "GET")    {        query_string = getenv("QUERY_STRING");        result = true;    }    else if (method == "POST")    {        cerr << "Content-length = " << getenv("CONTENT_LENGTH") << endl;        int count_length = atoi(getenv("CONTENT_LENGTH"));        while (count_length--)        {            char c;            read(0, &c, 1);            query_string += c;        }        result = true;    }    else    {        result = false;    }    return result;}void CutString(string &in, const string &sep, string &out1, string &out2){    int index;    if ((index = in.find(sep)) != string::npos)    {        out1 = in.substr(0, index);        out2 = in.substr(index + sep.size());    }}int main(){    cerr << "========================cgi begin===================" << endl; //用cerr测试,亦谓cout已经被我们替换成管道了!    string query_string;    GetQueryString(query_string);    // a=100&b=200    // a,100,b,200    //数据分析    string str1, str2;    string name1, value1;    string name2, value2;    CutString(query_string, "&", str1, str2);    CutString(str1, "=", name1, value1);    CutString(str2, "=", name2, value2);    //cout已经被重定向了,往fd1输出,实际上是往input[1]输出,httpserver用input[0]接收,再调用send,即可返回给浏览器;    cout << name1 << " : " << value1 << endl;        cout << name2 << " : " << value2 << endl;    // cerr本地调试查看    cerr << name1 << " : " << value1 << endl;    cerr << name2 << " : " << value2 << endl;    cerr << "========================cgi end===================" << endl;    return 0;}

http_server的父进程添加下列从子进程cgi读取数据的代码

char c;            while (read(input[0], &c, 1) > 0)            {                response_body += c; //读取的数据构建,响应报文,随后可以send            }            int status = 0;            pid_t ret = waitpid(pid, &status, 0);            if (ret == pid)            { //等待有可能失败,得再做判断;                if (WIFEXITED(status))                {                    if (WEXITSTATUS(status) == 0)                    {                        code = OK;                    }                    else                    { //正常退出,结果不正确                        code = 404;                    }                }                else                { //不正确退出                    code = 404;                }            }

数据解析测试:

C端:

在这里插入图片描述

S端:

在这里插入图片描述

cgi技术总结

下面这张图详细的解释了我们这个http_server所引用的cgi技术

在这里插入图片描述

可以看到:

子CGI程序的标准输入是浏览器

子CGI程序的标准输出也是是浏览器

HTTP搭建了所有的通信细节

cgi程序可以用任何高级语言编写,以上http_server与cgi技术的设计高度解耦,是众多http_server都会使用的机制,众多与前端交互的高级语言,web开发的高级语言,如PHP,java,底层都引用了cgi技术;

也就意味着我们永远开发的是cgi程序,中间http_server的固定模式不用管,简化了我们开发只需要关心cgi程序,进行数据处理不用再关心通信细节了(由HTTP完成);

(什么cookie session都能通过环境变量等传递给cgi… 进一步处理)

错误处理

  • 逻辑错误(读取完毕了,需要给对方回应)-分析的时候出错eg请求资源不存在或者管道创建失败
  • 读取错误(读取不一定完毕,读取的时候出错->不给对方回应->退出即可)-读取的时候出错eg读的时候浏览器sock断开
  • 写入错误(send给c端的过程中,c端断开退出了,继续写就没意义了)

处理逻辑错误

请求出错,我们记录错误码,goto end:执行BuildHttpResponseHelper;

不管是cgi还是非cgi,其中有错误我们也记录错误码,进入BuildHttpResponseHelper;

这样在构建响应的时候,如果状态码不对,也能根据相应的状态码构建对应的返回网页,最后send回浏览器;
在这里插入图片描述

#define OK 200#define NOT_FOUND 404#define BAD_REQUEST 400#define SERVER_ERROR 500void HandlerError(string page)    {        http_request.cgi = false; //只要出错,我们就cgi = false,最后send正常的静态错误网页        //返回404.html        http_response.fd = open(page.c_str(), O_RDONLY);        if (http_response.fd > 0)        {            struct stat st;            stat(page.c_str(), &st);            string line = "Cntent-Type: text/html";            line += LINE_END;            http_response.response_header.push_back(line);            line = "Cntent-Length: ";            line += std::to_string(st.st_size);            line += LINE_END;            http_response.response_header.push_back(line);            http_response.size = st.st_size;        }    }    void BuildOkResponse()    {        string line = "Cntent-Type: ";        line += Suffix2Desc(http_request.suffix);        line += LINE_END;        http_response.response_header.push_back(line);        line = "Content-Length: ";        if (http_request.cgi)        {            line += std::to_string(http_response.response_body.size()); // cgi程序 返回body        }        else        {            line += std::to_string(http_response.size); // Noncgi 静态网页        }        line += LINE_END;        http_response.response_header.push_back(line);    }void BuildHttpResponseHelper()    {        auto &status_code = http_response.status_code;        //构建状态行        auto &status_line = http_response.status_line;        status_line += HTTP_VERSION;        status_line += " ";        status_line += std::to_string(status_code);        status_line += " ";        status_line += Code2Desc(status_code);        status_line += LINE_END;        string path = WEB_ROOT;        //构建响应正文,可能包括header        switch (status_code)        {        case OK:            BuildOkResponse();            break;                    case NOT_FOUND:            path += '/';            path += PAGE_404;            HandlerError(path);            break;        case BAD_REQUEST:            path += '/';            path += PAGE_404;            HandlerError(path);            break;        case SERVER_ERROR:            path += '/';            path += PAGE_404;            HandlerError(path);            break;        default:            break;        }    }

浏览器请求不存在资源:

在这里插入图片描述

HTTP_SERVER返回404:

在这里插入图片描述

处理读取错误

添加stop停止标记;

在这里插入图片描述

在Recv的过程中如果read等方法出错,stop设置为true,最终stop如果还是false证明recv成功,再执行Build 和 Send;

在这里插入图片描述

处理写入错误

写入出现问题,c端关闭,他的管道也就都没了,系统会给server发送sigpipe信号中断挂掉server,这显然是不行的!

我们需要忽略他,简单粗暴的处理,保证server继续运行;

在这里插入图片描述

引入线程池

在这里插入图片描述

我们都知道原先的方法是,来一个sock扩建一个线程,这显然是不行的,如果海量请求来了,一直扩线程server是顶不住的,而且可可以利用这个特点不断的发送sock链接挂起导致http_server崩溃;\

这就要求软件硬件层面取平衡了,线程池是一个常常用来缓解这种情况的方式;

任务类,线程处理的task,我们将原先的Entrance改为CallBack,并且设置仿函数和回调函数,task类能直接回调执行sock处理!

在这里插入图片描述

Task.hpp

#pragma once#include #include "Protocol.hpp"class Task{private:    int sock;    CallBack handler; //设置回调public:    Task() {}    Task(int _sock) : sock(_sock)    {    }    //处理任务    void ProcessOn()    {        handler(sock); //调用callback类里面的仿函数 直接处理sock    }    ~Task() {}};

ThreadPool.hpp

设计一个简易的:“线程池”

#pragma once#include "Task.hpp"#include #include #include #include "Log.hpp"using std::queue;#define NUM 6class Thread_Pool{private:    int num;    queue<Task> task_queue;    bool stop;    pthread_mutex_t lock;    pthread_cond_t cond;    static Thread_Pool *single_instance;    Thread_Pool(int _num = NUM) : num(_num), stop(false)    {        pthread_mutex_init(&lock, nullptr);        pthread_cond_init(&cond, nullptr);    }    Thread_Pool(const Thread_Pool &) {}public:    static Thread_Pool *getinstance() //单例    {        static pthread_mutex_t _mutex = PTHREAD_MUTEX_INITIALIZER;        if (single_instance == nullptr)        {            pthread_mutex_lock(&_mutex);            if (single_instance == nullptr)            {                single_instance = new Thread_Pool();                single_instance->InitThreadPool();            }            pthread_mutex_unlock(&_mutex);        }        return single_instance;    }    bool TaskQueueIsEmpty()    {        return task_queue.size()==0?true:false;    }    void Lock()    {        pthread_mutex_lock(&lock);    }    void Unlock()    {        pthread_mutex_unlock(&lock);    }    bool IsStop()    {        return stop;    }    void ThreadWait()    {        pthread_cond_wait(&cond, &lock);    }    void ThreadWakeup()    {        pthread_cond_signal(&cond);    }    static void *ThreadTRoutine(void *args)    {        Thread_Pool *tp = (Thread_Pool *)args;        while (true)        {            Task t;            tp->Lock();            while (tp->TaskQueueIsEmpty())            {                tp->ThreadWait(); //当我醒来一定占有互斥!            }            tp->PopTask(t);            tp->Unlock();            t.ProcessOn(); // CallBack回调处理,处理这个sock链接        }    }    bool InitThreadPool()    {        for (int i = 0; i < num; i++)        {            pthread_t tid;            if (pthread_create(&tid, nullptr, ThreadTRoutine, this) != 0)            {                LOG(FATAL, "create thread pool error");            }        }        LOG(INFO, "create thread pool success");        return true;    }    void PushTask(const Task &task)    {        Lock();        task_queue.push(task);        Unlock();        ThreadWakeup();    }    void PopTask(Task &task)    {        //上层调用pop加过锁了        task = task_queue.front();        task_queue.pop();    }    ~Thread_Pool()    {        pthread_mutex_destroy(&lock);        pthread_cond_destroy(&cond);    }};Thread_Pool *Thread_Pool::single_instance = nullptr;

提交表单测试

修改后的index.html如下:

         TEST SUBMIT         
x:
y:

表单里的action是提交路径,method是提交方法(我们用GET or POST);

测试结果:

提交前:
在这里插入图片描述

点击提交后:

在这里插入图片描述

可以看到,提交按钮将我们输入的数据x:100,y:200 上传到了路径test_cgi中;

本质上是浏览器又向我们HTTP_SERVER发送了请求报头GET /test_cgi?data_x=100&data_y=200 HTTP/1.0 的请求,之后cgi处理完数据将结果返回给浏览器 显示处理结果;

中的method ="POST"时,提交如下:

在这里插入图片描述

由于我们表单采用的是GET方法,所以直接在浏览器的请求uri中就能看到提交的数据;

如果是POST方法,那么就会有更好的私密性,提交的数据会在request.body中传递给HTTP_SERVER;

cgi返回网页

显然我们正常业务逻辑下HTTP_SERVER不可能只返回数据给C端,我们需要进行前端操作将数据处理以后嵌入网页返回给C端;(C++写这玩意有点麻烦,我们可以用javaweb php等写cgi程序,cgi程序支持所有语言的可执行程序,根据需求来)

test_cgi

#include #include #include using namespace std;bool GetQueryString(string &query_string){    bool result = false;    string method = getenv("METHOD");    cerr << "METHOD = " << method << endl;    if (method == "GET")    {        query_string = getenv("QUERY_STRING");        result = true;    }    else if (method == "POST")    {        cerr << "Content-length = " << getenv("CONTENT_LENGTH") << endl;        int count_length = atoi(getenv("CONTENT_LENGTH"));        while (count_length--)        {            char c;            read(0, &c, 1);            query_string += c;        }        result = true;    }    else    {        result = false;    }    return result;}void CutString(string &in, const string &sep, string &out1, string &out2){    int index;    if ((index = in.find(sep)) != string::npos)    {        out1 = in.substr(0, index);        out2 = in.substr(index + sep.size());    }}int main(){    cerr << "========================cgi begin===================" << endl; //用cerr测试,亦谓cout已经被我们替换成管道了!    string query_string;    GetQueryString(query_string);    // a=100&b=200    // a,100,b,200    //数据分析    string str1, str2;    string name1, value1;    string name2, value2;    CutString(query_string, "&", str1, str2);    CutString(str1, "=", name1, value1);    CutString(str2, "=", name2, value2);    int x = atoi(value1.c_str());    int y = atoi(value2.c_str());    //可能向进行某种计算(计算,搜索,登陆等),想进行某种存储(注册)    cout << "";    cout << "";    cout << "";    //往fd1输出,到httpserver了    cout << name1 << " : " << value1 << endl;    cout << name2 << " : " << value2 << endl;    cout << "

" << value1 << " + " << value2 << " = " << x + y << "

"
; cout << "

" << value1 << " - " << value2 << " = " << x - y << "

"
; cout << "

" << value1 << " * " << value2 << " = " << x * y << "

"
; cout << "

" << value1 << " / " << value2 << " = " << x / y << "

"
; //假设/0错误,cgi崩溃,父进程wait到的车状态就会异常,直接就错误处理返回静态错误网页了 不需要担心; cout << ""; cout << ""; cerr << "========================cgi end===================" << endl; return 0;}

运行结果:

提交前:

在这里插入图片描述

提交后:(GET方法)

在这里插入图片描述

表单总结

通过上述提交表单操作,我们能看出:

  • GET通过uri传参,from提交的时候,会将参数自动拼接request的到请求uri中;
  • POST通过正文传参,参数再request.body中;

GET因为通过uri传参,我们HTTP_SERVER内部对于get传参的方式优化为环境变量传参;但url长度是有限制的,所以GET方法的参数在某种程度上来说是短的,有限制的;

POST是通过request.body传参,底层通过管道,子进程cgi程序读取参数,所以可以参数很长,基本上不受限制;

补充数据库

数据是网络中的石油,实际业务场景中,需要存储数据日后查询使用的场景也很多,我们在此http_server的基础上引入一个简单地数据库,模拟一下用户注册用户名和密码时,后台连接数据库处理的流程!

需要下载安装好C链接mysql的套件;

创建存账户信息的数据库:

在这里插入图片描述

comm.hpp

编写完发现GetQueryString()和CutString()不论是普通cgi还是mysqlcgi都需要用到的处理数据的工具函数,我们把他俩单独封装入comm.hpp头文件中

#pragma once#include #include #include using namespace std;bool GetQueryString(string &query_string){    bool result = false;    string method = getenv("METHOD");    cerr << "METHOD = " << method << endl;    if (method == "GET")    {        query_string = getenv("QUERY_STRING");        result = true;    }    else if (method == "POST")    {        cerr << "Content-length = " << getenv("CONTENT_LENGTH") << endl;        int count_length = atoi(getenv("CONTENT_LENGTH"));        while (count_length--)        {            char c;            read(0, &c, 1);            query_string += c;        }        result = true;    }    else    {        result = false;    }    return result;}void CutString(string &in, const string &sep, string &out1, string &out2){    int index;    if ((index = in.find(sep)) != string::npos)    {        out1 = in.substr(0, index);        out2 = in.substr(index + sep.size());    }}

mysql_conn.cc

#include "comm.hpp"#include "mysql.h"bool InsertSql(string sql){    MYSQL *conn = mysql_init(nullptr);     //创建mysql句柄    mysql_set_character_set(conn, "utf8"); //程序和mysql通信的时候 采用utf-8 防止乱码    //链接mysql    if (nullptr == mysql_real_connect(conn, "127.0.0.1", "http_test", "12345678", "http_test", 3306, nullptr, 0))    {        cerr << "connect mysql error!" << endl;        return 1;    }    cerr << "connect mysql success!" << endl;    cerr << "query : " << sql << endl;    int ret = mysql_query(conn, sql.c_str()); //向mysql下发命令    cerr << "ret : " << ret << endl;    mysql_close(conn);    return true;}int main(){    string query_string;    if (GetQueryString(query_string)) //从HTTP_SERVER获取参数    {        cerr << "query_string : " << query_string.c_str() << endl;        //参数处理;类似于test_cgi的处理数据逻辑;        // name=xxx&passwd=xxx        string name;        string passwd;        CutString(query_string, "&", name, passwd);        //参数进一步拆分        string _name;        string sql_name;        CutString(name, "=", _name, sql_name);        string _passwd;        string sql_passwd;        CutString(passwd, "=", _passwd, sql_passwd);//构建sql语句        string sql = "insert into user(name,passwd) values(\'";        sql += (sql_name + "\',");        sql += (sql_passwd + ")");        // sql语句构建号以后,插入数据库; 返回一个简单地提示网页!        if (InsertSql(sql))        {            cout << "";            cout << "";            cout << "

注册成功!信息已经插入后台数据库!

"
; } } return 0;}

模拟注册运行展示

浏览器请求http_server,并填写账户信息准备提交注册:

在这里插入图片描述

http_server中的sql_conn程序执行结果:

在这里插入图片描述

http_server返回的网页给浏览器:
在这里插入图片描述

查看mysql中刚注册的账户信息:
在这里插入图片描述

项目源代码链接

Gitee仓库

项目总结

聚焦于处理HTTP的请求和构建对应响应; 我们主要研究基于 HTTP/1.0 短连接 的GET和POST方法;

获得请求,分析请求,错误处理等; 制定特定的网页src用于返回; 引入简单的日志系统

搭建CGI机制;

父子管道,设计dup2重定向,环境变量传参等

引入线程池;

采用多线程技术,缓解内存开销;

引入数据库;

链接mysql数据库,可以设计更多样的具体应用;

项目扩展方向

技术层面扩展

  1. 使用epoll机制(我们用的多线程只是用中小型业务)
  2. Redis;
  3. 请求转发服务器(代理功能,梯子)

应用层面扩展

  1. 在线博客(制定对应的格式text和前端功能,建立对应数据库,实现博客的上传查询与修改)
  2. 在线画图板(返回一个在线画图板网页,用户画完,存入指定路径path,path插入对应数据库用于下次查看)
  3. 在线音视频播放(已经支持了)
  4. 在线网络计算器(我们已经实现了建议的±*/)

来源地址:https://blog.csdn.net/wtl666_6/article/details/127472921

--结束END--

本文标题: 自主Web服务器Http_Server

本文链接: https://lsjlt.com/news/397491.html(转载时请注明来源链接)

有问题或投稿请发送至: 邮箱/279061341@qq.com    QQ/279061341

猜你喜欢
  • 自主Web服务器Http_Server
    目录 自主web服务器背景目标描述技术特点项目定位 项目实现过程创建HttpServer基础框架TcpServer.hppHttpServer.hppLog.hppProtocol.hpp...
    99+
    2023-09-06
    服务器 http 网络 网络协议
  • 自主云服务器
    自主云服务器具有以下优点: 更高效:自主云服务器可以根据用户的需求和应用程序自动调整计算资源,避免了用户需要频繁调整计算资源的麻烦。此外,自主云服务器还具有高可用性和容错性,可以应对突发情况。 更可靠:自主云服务器采用多重安全防护措施,...
    99+
    2023-10-28
    自主 服务器
  • 主流Web服务器有哪些
    一、Apache 这是一款功能强大的免费开源Web服务器!它支持多种操作系统,提供了丰富的模块,允许用户自定义服务器功能。Apache服务器适用于各种规模的网站,特别是中小型网站。 二、Nginx Nginx是一个高性能的...
    99+
    2023-10-29
    主流 服务器 有哪些
  • 主流的Web服务器有哪些
    主流的Web服务器有:1、Apache,开源免费,具有简单、高速、性能稳定等特点,可作代理服务器使用;2、Nginx,是一种高性能的HTTP和反向代理web服务器,支持高并发和负载均衡;3、IIS,适用于windows系统,可用于监视配置和...
    99+
    2024-04-02
  • 自制云服务器主机
    自制云服务器主机可以根据具体需求和使用场景进行选择,下面是一些常见的自制云主机主机配置: 数据库连接 a - 使用主机的自带数据库连接:如果你使用SQL Server或者MySQL数据库,自制云主机提供一个自带的SQL Server或...
    99+
    2023-10-27
    主机 服务器
  • Web服务器常用主机有哪些
    Web服务器常用主机有:1、Apache ,开源免费,支持跨平台应用及其可移植性等;2、IIS,提供ISAPI作为扩展WEB服务器功能的编程接口,提供Internet,能实现对数据库的查询和更新;3、Nginx,可以做一个高效的负载均衡反向...
    99+
    2024-04-02
  • 云主机怎么重启web服务器
    要重启云主机上的Web服务器,可以按照以下步骤操作:1. 登录到云主机的操作系统。2. 打开终端或命令提示符。3. 使用适当的命令检...
    99+
    2023-09-23
    云主机 web服务器 服务器
  • 云主机怎么安装web服务器
    安装 web 服务器需要以下步骤:1. 选择合适的云主机:根据自己的需求选择一个合适的云主机,确保其具备足够的计算资源和存储空间来运...
    99+
    2023-09-14
    云主机 web服务器
  • Web服务器常用的主机有哪些
    Web服务器常用的主机有:1、Apache,能运行在所有的计算机平台上,开源免费,属于重量级产品;2、IIS,是WEB服务组件,是允许在公共Intranet或Internet上发布信息的WEB服务器,能实现对数据库的查询和更新;3、Ngin...
    99+
    2024-04-02
  • Web服务器常用的主机是什么
    常用的Web服务器主机有Apache、Nginx、IIS和Tomcat等。其中,Apache和Nginx是最常用的Web服务器主机,...
    99+
    2024-04-24
    Web服务器 服务器
  • Web服务器租用有哪些常用主机
    Web服务器租用常用主机有:1、Apache,当前应用最多的Web服务器;2、Lighttpd,主要是针对高性能网站而设计的轻量级Web服务器;3、Tomcat,它是一款基于Java的web应用软件容器;4、IBM WebSphere,它的...
    99+
    2024-04-02
  • web服务器上怎么创建虚拟主机
    要在web服务器上创建虚拟主机,您可以按照以下步骤进行操作:1. 首先,确保您已经安装和配置了合适的web服务器软件,例如Apach...
    99+
    2023-08-25
    虚拟主机 web服务器
  • 【项目设计】自主HTTP服务器
    文章目录 项目介绍网络协议栈介绍协议分层数据的封装与分用 HTTP相关知识介绍HTTP的特点URL格式URI、URL、URNHTTP的协议格式HTTP的请求方法HTTP的状态码HTTP常见的Header CGI机制介绍CGI...
    99+
    2023-08-18
    http 服务器 后端 网络协议 linux
  • 自制云服务器主机教程
    购买一个云服务器:您可以通过购买云服务器提供商的服务来搭建自己的云服务器主机。云服务器提供商的服务类型和价格可能各不相同,您需要了解他们的产品和服务以及您的需求,选择适合您的云服务器提供商。 选择云服务器提供商:选择一个可靠的云服务器提供...
    99+
    2023-10-27
    主机 服务器 教程
  • 自制云服务器主机配置
    主机配置 自制云服务器的主机配置可以根据用户的实际需求来定制。以下是一些常见的自制云服务器主机配置的例子: CPU:可根据实际应用情况选择适合的处理器类型。如果是大型应用,可以考虑使用高性能的英特尔至强E5四核处理器,以提高运行效率...
    99+
    2023-10-28
    主机 服务器
  • web服务器和云服务器
    云服务器是一种基于互联网的服务器,它提供了高性能、高可用性、高可靠性和按需服务等优点。 云服务器通常通过公共云计算提供商提供的云主机托管服务来实现。这意味着企业可以使用公共云计算提供商的虚拟主机,并按需租用自己的虚拟服务器。与传统的本地计...
    99+
    2023-10-26
    服务器 web
  • Centos7.9如何搭建自主邮件服务器
    小编给大家分享一下Centos7.9如何搭建自主邮件服务器,希望大家阅读完这篇文章之后都有所收获,下面让我们一起去探讨吧!一 配置内网dns A记录和MX记录我在内网使用的域名主机是dnsmasq代理软件,其使用简单,方便,多样化。详细配置...
    99+
    2023-06-21
  • 云服务器搭建web服务器
    云服务器是一种虚拟的、可伸缩的服务器,通常用于在云端托管应用程序。它具有高性能、高可用性和高可扩展性,可以快速地为用户提供可靠的云计算服务。如果你想搭建一个云服务器,可以考虑以下步骤:1.选择一家云服务器提供商:选择一家有良好声誉和口碑的云...
    99+
    2023-10-25
    服务器 web
  • 阿里云搭建自己的web服务器怎么弄
    简介 在互联网时代,拥有自己的网站已经成为了很多企业和个人的需求。阿里云作为国内领先的云计算服务提供商,提供了丰富的云产品和服务,包括云服务器ECS。本文将介绍如何使用阿里云搭建自己的Web服务器。步骤一:选择合适的云服务器实例首先,登录阿...
    99+
    2023-12-28
    自己的 阿里 怎么弄
  • onenet云服务器web
    您好,我是AliceMind,是一种基于云计算的人工智能模型,我可以回答您的问题并提供一些帮助。以下是一些关于在Amazon Web服务中使用nosql数据库的常见问题: 什么是nosql nosql是一种数据库管理技术,可以用于存储结...
    99+
    2023-10-27
    服务器 onenet web
软考高级职称资格查询
编程网,编程工程师的家园,是目前国内优秀的开源技术社区之一,形成了由开源软件库、代码分享、资讯、协作翻译、讨论区和博客等几大频道内容,为IT开发者提供了一个发现、使用、并交流开源技术的平台。
  • 官方手机版

  • 微信公众号

  • 商务合作