基于Linux的Web小型服务器HTTP项目的自主实现

       本博客介绍的是自主实现一个HTTP服务器项目的过程。主要包括项目所用的技术、搭建框架和思路、项目中的技术难点、遇到的问题以及使如何解决的。完成该项目,需要掌握的预备知识主要有:系统编程、多线程编程、网络套接字编程、网络分层协议(尤其是HTTP协议、TCP协议等)。原码链接为:https://gitee.com/zhupeng-0201/mini_-http-server

       通过该项目的学习,我的收获有:

  • 采用C/S模型,编写支持中小型应用的http,并结合mysql,理解常见互联网应用行为。做完该项目,可以从技术上完全理解从上网开始,到关闭浏览器的所有技术细节。
  • 从零开始完成web服务器开发,对http协议、tcp/ip协议有了更加深入的理解;
  • 对操作系统中的进程通信、进程信号、同步互斥、多线程等概念有了更清晰的认识;
  • 综合提升了我的系统编程、网络套接字编程的能力;
  • 理解了HTTP中CGI模式的基本原理和实现过程;

目录

1. 项目介绍:

1.1 项目概述:

1.2 开发环境:

1.3 主要技术:

1.4 实现的功能:

1.5 项目功能演示:

1.5.1 功能1 在线计算器

 1.5.2 功能2 基于MySQL实现用户注册信息上传到数据库

2. 项目框架:

2.1 项目部署:

3. 项目过程中的重难点:

3.1 HTTP1.0 和HTTP1.1 的区别

3.2 URI & URL & URN

3.3 HTTP请求与响应报文格式

3.4 HTTP CGI机制

3.5 线程池的介入

3.6 C语言链接MySQL

4. 项目实现:

4.1 HttpServer类

4.2 ThreadPool类

4.3 Task类

4.4 Protocol类(最重要的部分)

4.4.1 部署HttpRequest类

4.4.2 部署HttpResponse类

4.4.3 部署CallBack类

4.4.4 部署EndPoint类

4.5 部署CGI子程序

4.5.1 网络计算器

4.5.2 用户注册信息上传到数据库

4.6 日志类

4.7 Util类

4.7.1 按行读取

4.7.2 字符串切分

5. 项目测试:

5.1 发布版本功能测试

5.2 抓包测试

5.2.1 telnet工具

5.2.2 postman工具

6. 项目总结:


1. 项目介绍:

1.1 项目概述:

        本项目采用C/S模型(或B/S模式:Browser),从零开始编写支持中小型应用的http服务器,并结合mysql,支持上传数据到数据库。整个项目大体分为接收请求、分析请求、构建响应、发送响应等几个部分。本服务器能够根据用户的请求返回静态网页和表单,能够处理常见的错误请求。此外,为了实现服务器与浏览器更好地交互,本项目融入了CGI机制,可以对用户的一些数据请求调用CGI子程序作出相应的处理并返回处理结果。本项目最终实现的功能包括简单的网络计算器、用户注册信息提交上传到数据库等,但是并不仅限于此,开发者可以根据自己的需求来编写CGI程序实现服务器功能的扩展!

1.2 开发环境:

  • C / C++ 编程语言
  • Centos7.0 / VS Code 开发平台
  • vim / gcc / g++ / gdb / Makefile 开发工具
  • Postman / telnet / Chrome Browser 测试工具

1.3 主要技术:

  • 网络协议(HTTP协议/TCP/IP协议)
  • 网络编程(Socket套接字编程)
  • 设计模式(单例模式)
  • 操作系统(进程创建、进程替换、进程间通信(环境变量、管道)、信号、同步与互斥)
  • 系统编程(多线程编程、线程池)
  • CGI模式及实现原理
  • MySQL语法(使用C接口连接)
  • shell脚本语言、基础的html语言
  • 测试工具:telnet、Postman

1.4 实现的功能:

完成整个服务器httpserver、CGI子程序的编写之后,我们就可以实现一些基本的功能了:

  1. 完成test_cgi子程序,能够实现网络版本的计算器
  2. 通过C语言链接mysql,编写mysql_cgi子程序,实现用户的注册信息提交到数据库

1.5 项目功能演示:

1.5.1 功能1 在线计算器

启动服务器,用户在浏览器内通过域名和端口号组成的url访问即可:

 输入两个数即可得到简单的计算结果

 1.5.2 功能2 基于MySQL实现用户注册信息上传到数据库

启动服务器,用户在浏览器内通过域名和端口号组成的url访问即可:

 

 此时在后台数据库里面就能看到注册信息:

当然本项目的核心在服务器处理HTTP协议细节分析和处理上,上面演示的都是服务器正常处理的情况,一些错误请求也能够正确处理。

2. 项目框架:

项目框架图

2.1 项目部署:

编写的代码如下:

  1. main.cc 主函数,用来编译整个项目;
  2. HttpServer.hpp 存放HttpServer类。使用SockAPI和单例模式,创建套接字、绑定、监听、获取新连接,并把新连接封装成一个Task,push进线程池待处理;
  3. ThreadPool.hpp 存放单例ThreadPool类。使用POSXI线程库的线程、互斥量和条件变量编写一个单例模式的线程池。一有Task到来就获取一个线程来处理,即Task.run();
  4. Task.hpp 提供任务类,并提供任务处理的方法(通过一个回调函数调取Protocol里面的类)
  5. Protocol.hpp 设计HTTP协议,提供处理方法,包括接收request,分析request,构建response,发送response;
  6. Log.hpp 存放打印日志函数,可以打印日志;
  7. Util.hpp 存放工具类,该类提供了一些字符串处理的方法,方便使用;

附属文件如下:

  1. output目录为最终发布版本;
  2. cgi目录下包含CGI模式下的子程序代码,供服务器调用;
  3. wwwroot为该服务器的根目录,包含可访问的资源(网页资源和cgi程序),服务器启动自动从该目录下进行路径搜索资源;
  4. readme.txt为说明文档;
  5. build.sh为shell脚本,用来运行Makefile文件,发布output目录;
  6. Makefile文件用来编译代码生成可执行程序的;

output目录为最终发布版本,里面包含一个httpserver可执行程序,和wwwroot服务器根目录,里面包含可被访问的资源(一些网页资源和cgi可执行程序),具体如下所示。

 

 

3. 项目过程中的重难点:

3.1 HTTP1.0 和HTTP1.1 的区别

目前主流服务器使用的是http/1.1版本,本项目按照http/1.0版本来完成。http1.1和1.0的主要区别在于长连接和短连接。

  • 在 HTTP 1.0 中,默认使用的是短连接,也就是说,每次请求都要重新建立一次连接。HTTP是基于TCP/IP协议的,每一次建立或者断开都需要重新三次握手、四次挥手,比较耗时。
  • 在HTTP 1.1 中,默认使用长连接,默认开启Connection: keep-alive 。HTTP 1.1连接方式由 流水线方式 和 非流水线方式。
    流水线方式 :客户在收到HTTP响应报文之前就能接着发送新的请求报文。
    非流水线方式: 客户在收到HTTP前一个响应之后,才能发送下一个请求。

HTTP协议的特点:

  • 客户/服务器模式(B/S,C/S)
  • 简单快速,HTTP服务器的程序规模小,因而通信速度很快。
  • 灵活,HTTP允许传输任意类型的数据对象,正在传输的类型由Content-Type加以标记。
  • 无连接,每次连接只处理一个请求。服务器处理完客户的请求,并收到客户的应答后,即断开连接。采用这种方式可以节省传输时间。(http/1.0具有的功能,http/1.1兼容)
  • 无状态(http协议每当有新的请求产生,就会有对应的新响应产生。协议本身并不会保留你之前的一切请求或者响应,这是为了更快的处理大量的事务,确保协议的可伸缩性。
    可是,随着web的发展,因为无状态而导致业务处理变的棘手起来。比如保持用户的登陆状态。http/1.1虽然也是无状态的协议,但是为了保持状态的功能,引入了cookie技术)

3.2 URI & URL & URN

  • URI,是uniform resource identifier,统一资源标识符,用来唯一的标识一个资源
  • URL,是uniform resource locator,统一资源定位符,它是一种具体的URI,即URL可以用来标识一个资源,而且还指明了如何locate这个资源。
  • URN,uniform resource name,统一资源命名,是通过名字来标识资源,比如mailto:javanet@java.sun.com。

浏览器URL格式:

  • HTTP(超文本传输协议)是基于TCP的连接方式进行网络连接
  • HTTP/1.1版本中给出一种持续连接的机制(长链接)
  • 绝大多数的Web开发,都是构建在HTTP协议之上的Web应用

HTTP URL (URL是一种特殊类型的URI,包含了如何获取指定资源)的格式如下:
http://host[":"port][abs_path][?参数] 

url示例:http://www.aspxfans.com:8080/news/index.asp?boardID=5&ID=24618&page=1

  • http 表示要通过HTTP协议来定位网络资源
  • host 表示合法的Internet主机域名或者IP地址,本主机IP:127.0.0.1
  • port 指定一个端口号,为空则使用缺省端口80
  • abs_path 指定请求资源的URI,如果URL中没有给出abs_path,那么当它作为请求URI时,必须以“/”的形式给出,通常这个工作浏览器自动帮我们完成。
  • ?后面可能会有参数

输入: www.baidu.com,浏览器自动转换成:http(s)://www.baidu.com/

3.3 HTTP请求与响应报文格式

 具体http细节说明:

HTTP请求报文

HTTP响应报文

利用几个接口测试工具,有利于学好HTTP协议:
telnet(linux)、postman(windows)、WFetch(windows)、Wireshark(windows)
前两个比较推荐,用法也比较简单,具体可搜索其他网络教程。

3.4 HTTP CGI机制

        CGI(Common Gateway Interface) 是WWW技术中最重要的技术之一,CGI是外部应用程序(CGI程序)与WEB服务器之间的接口标准,是在CGI程序和Web服务器(http_server)之间传递信息的过程,按CGI 编写的程序可以扩展服务器功能。CGI 应用程序能与浏览器进行交互,还可通过数据API与数据库服务器等外部数据源进行通信,从数据库服务器中获取数据。格式化为HTML文档后,发送给浏览器,也可以将从浏览器获得的数据放到数据库中。几乎所有服务器都支持CGI,可用任何语言编写CGI。使用命令行参数或环境变量表示服务器的详细请求,服务器与浏览器通信采用标准输入输出方式,是标准CGi的做法。

        如何理解CGI?浏览器除了从服务器下获得资源(网页,图片,文字等),有时候还有能上传一些东西(提交表单,注册用户之类的),看看我们目前的http只能进行获得资源,并不能够进行上传资源,所以目前http并不具有交互式。为了让我们的网站能够实现交互式,我们需要使用CGI完成。注意,http提供CGI机制,CGI程序需要自己实现!理论上,可以使用任何语言来编写CGI程序。最终只要能提供一个可执行程序即可。http_server可以通过创建子进程、进程替换来调用这个子程序。

在项目实现上,要理解CGI,首先的理解GET方法和POST方法的区别

  • GET方法从浏览器传参数给http服务器时,是需要将参数跟到URI后面的
  • POST方法从浏览器传参数给http服务器时,是需要将参数放的请求正文的。
  • GET方法,如果没有传参,http按照一般的方式进行,返回资源即可
  • GET方法,如果有参数传入,http就需要按照CGI方式处理参数,并将执行结果(期望资源)返回给浏览器。
  • POST方法,一般都需要使用CGI方式来进行处理

能调用cgi机制,一定是启动了cgi机制,也就是使用POST方法、GET方法带参数或访问可执行程序:

  • GET方法带参数
  • POST方法
  • 访问资源是一个可执行程序
HTTP的CGI机制

3.5 线程池的介入

本项目使用了多线程编程,并引入了线程池,原因在于:

  • 大量链接过来导致服务器内部进程或者线程暴增,进而导致服务器效率严重降低或者挂掉;
  • 节省链接请求到来时,创建线程的时间成本;
  • 让服务器的效率在一个恒定的稳定区间内(线程个数不增多,CPU调度成本不变);

3.6 C语言链接MySQL

在实现项目的第二个功能时,引入了数据库,重点在于:

  • 使用C接口库来进行连接MySQL;
  • 具体用法可参考其他网络教程;

4. 项目实现:

4.1 HttpServer类

该类主要包括TcpServer和HttpServer类,编写SocketAPI,主要过程为:
TcpServer为单例模式,创建套接字、绑定端口号、将套接字设置为监听状态;
HttpServer主要负责获取连接,并将器封装成任务,然后放进线程池中进行任务处理。

注意事项:

  • 获取单例的方法中用到的互斥量使用PTHREAD_MUTEX_INITIALIZER字段进行初始化,这样的好处就是改互斥量出来作用域可以自动销毁,更加方便
  • 服务器初始化的时候,将SIGPIPE设置为SIG_IGN,忽略该信号。考虑到服务器给客户端发送响应时,也就是往sock写入时,客户端关闭连接,此时操作系统会向服务器发送SIGPIPE信号终止服务器,导致服务器崩溃,这样显然是不行的,所以我们选择在服务器初始化的时候就忽略该信号。
#include "Log.hpp"
#include "Task.hpp"
#include "ThreadPool.hpp"

#define BACKLOG 5
#define PORT 8081

class TcpServer{
    private:
        int port;
        int listen_sock;
        static TcpServer *svr;
    private:
        TcpServer(int _port):port(_port),listen_sock(-1)
        {}
        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();
                }
                pthread_mutex_unlock(&lock);
            }
            return svr;
        }
        void InitServer()
        {
            Socket();//创建套接字
            Bind(); //绑定(本地端口号)
            Listen();//监听
            LOG(INFO, "tcp_server init ... success");
        }
        void Socket()
        {
            listen_sock = socket(AF_INET, SOCK_STREAM, 0);
            if(listen_sock < 0){
                LOG(FATAL, "socket error!");
                exit(1);
            }
            int opt = 1;
            setsockopt(listen_sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
            LOG(INFO, "create socket ... success");
        }
        void Bind()
        {
            struct sockaddr_in local;
            memset(&local, 0, sizeof(local));
            local.sin_family = AF_INET;
            local.sin_port = htons(port);
            local.sin_addr.s_addr = INADDR_ANY; //云服务器不能直接绑定公网IP

            if(bind(listen_sock, (struct sockaddr*)&local, sizeof(local)) < 0){
                LOG(FATAL, "bind error!");
                exit(2);
            }
            LOG(INFO, "bind socket ... success");
        }
        void Listen()
        {
            if(listen(listen_sock, BACKLOG) < 0){
                LOG(FATAL, "listen socket error!");
                exit(3);
            }
            LOG(INFO, "listen socket ... success");
        }
        int Sock()
        {
            return listen_sock;
        }
        ~TcpServer()
        {
            if(listen_sock >= 0) close(listen_sock);
        }
};
TcpServer* TcpServer::svr = nullptr;

class HttpServer{
    private:
        int port;
        bool stop;
    public:
        HttpServer(int _port = PORT): port(_port),stop(false)
        {}
        void InitServer()
        {
            //信号SIGPIPE需要进行忽略,如果不忽略,在写入时候,断开连接的话,可能直接崩溃server
            signal(SIGPIPE, SIG_IGN); 
        }
        void Loop()
        {
            TcpServer *tsvr = TcpServer::getinstance(port);//这里已经完成了创建套接字、绑定、监听
            LOG(INFO, "Loop begin");
            while(!stop){
                struct sockaddr_in peer;
                socklen_t len = sizeof(peer);
                int sock = accept(tsvr->Sock(), (struct sockaddr*)&peer, &len);//获取新连接
                if(sock < 0){
                    continue;
                }
                LOG(INFO, "Get a new link");
				
                Task task(sock);//获取新连接之后,创建一个任务task
                ThreadPool::getinstance()->PushTask(task); //把该任务push进线程池,让一个新线程来处理它
            }
        }
        ~HttpServer()
        {}
};

4.2 ThreadPool类

该项目频繁获取连接,需要派出一个线程去处理相应的任务,如果每次来一个连接就去创建一个线程,断开连接就销毁线程的话,这样对操作系统开销比较大,同时也会带来一定的负担。如果使用线程池的话,来一个任务就立即处理,不需要去创建线程,这样就节省了创建线程时间,同时也可以防止服务器线程过多导致操作系统过载的问题。

该类用到了POSIX线程库的一套接口

#pragma once

#include <iostream>
#include <queue>
#include <pthread.h>

#include "Log.hpp"
#include "Task.hpp"

#define NUM 6

class ThreadPool{
    private:
        int num;
        bool stop;
        std::queue<Task> task_queue;
        pthread_mutex_t lock;
        pthread_cond_t cond;

        ThreadPool(int _num = NUM):num(_num),stop(false)
        {
            pthread_mutex_init(&lock, nullptr);
            pthread_cond_init(&cond, nullptr);
        }
        ThreadPool(const ThreadPool &){}

        static ThreadPool *single_instance;
    public:
        static ThreadPool* getinstance()
        {
            static pthread_mutex_t _mutex = PTHREAD_MUTEX_INITIALIZER;// 静态的锁,不需要destroy 
            if(single_instance == nullptr){
                pthread_mutex_lock(&_mutex);
                if(single_instance == nullptr){
                    single_instance = new ThreadPool();
                    single_instance->InitThreadPool();
                }
                pthread_mutex_unlock(&_mutex);
            }
            return single_instance;
        }

        bool IsStop()
        {
            return stop;
        }
        bool TaskQueueIsEmpty()
        {
            return task_queue.size() == 0 ? true : false;
        }
        void Lock()
        {
            pthread_mutex_lock(&lock);
        }
        void Unlock()
        {
            pthread_mutex_unlock(&lock);
        }
        void ThreadWait()
        {
            pthread_cond_wait(&cond, &lock);
        }
        void ThreadWakeup()
        {
            pthread_cond_signal(&cond);
        }
        static void *ThreadRoutine(void *args)
        {
            ThreadPool *tp = (ThreadPool*)args;

            while(true){
                Task t;
                tp->Lock();
                while(tp->TaskQueueIsEmpty()){
                    tp->ThreadWait(); //当我醒来的时候,一定是占有互斥锁的!
                }
                tp->PopTask(t);
                tp->Unlock();
				
                t.ProcessOn();//任务的处理一定要在锁外面
            }
        }
        bool InitThreadPool()
        {
            for(int i = 0; i < num; i++){
                pthread_t tid;
                if( pthread_create(&tid, nullptr, ThreadRoutine, this) != 0){
                    LOG(FATAL, "create thread pool error!");
                    return false;
                }
            }
            LOG(INFO, "create thread pool success!");
            return true;
        }
        void PushTask(const Task &task) //in
        {
            Lock();
            task_queue.push(task);
            Unlock();
            ThreadWakeup();
        }
        void PopTask(Task &task) //out
        {
            task = task_queue.front();
            task_queue.pop();
        }
        ~ThreadPool()
        {
            pthread_mutex_destroy(&lock);
            pthread_cond_destroy(&cond);
        }
};

ThreadPool* ThreadPool::single_instance = nullptr;

4.3 Task类

每一个获取到的连接都可以封装称为一个任务类,然后放进线程池中,让线程池中的线程取出然后执行对应的方法

#include <iostream>
#include "Protocol.hpp"
class Task{
    private:
        int sock;
        CallBack handler; //设置回调
    public:
        Task()
        {}
        Task(int _sock):sock(_sock)
        {}
        //处理任务
        void ProcessOn()
        {
            handler(sock);
        }
        ~Task()
        {}
};

4.4 Protocol类(最重要的部分)

4.4.1 部署HttpRequest类

        整个项目主要分析GET和POST请求,需要知道的是,GET方法可以获取服务器资源,也可以上传数据,且是通过uri中’?'后面的参数进行提交。POST是直接通过正文上传数据,且报头中有Content-Length字段标识正文的长度大小。

协议请求类主要用来存放描述请求类的一些成员,必须要有的四个:

  • request_line:请求行
  • request_header:请求报头:使用一个vector存放请求报头的属性行,分行存储
  • blank:空行
  • request_body:请求报头(根据请求方法进行获取,POST方法就进行读取)

成员变量cgi标识是否启用cgi机制:

  • cgi:只有上传了数据,都需要使用cgi机制进行处理,或者说是请求资源是一个可执行程序,也需要启动cgi
class HttpRequest{
    public:
        std::string request_line; //请求行
        std::vector<std::string> request_header; //请求报头
        std::string blank; //空行
        std::string request_body; //请求正文

        //解析完毕之后的结果
        std::string method;
        std::string uri; //path?args
        std::string version;

        std::unordered_map<std::string, std::string> header_kv;
        int content_length;
        std::string path;
        std::string suffix;
        std::string query_string;

        bool cgi;
        int size;
    public:
        HttpRequest():content_length(0), cgi(false){}
        ~HttpRequest(){}
};

4.4.2 部署HttpResponse类

根据响应协议内容定制的四个成员变量:

  • status_line:状态行
  • response_header:响应报头
  • blank:空行
  • response_body:响应正文
class HttpResponse{
    public:
        std::string status_line; //响应的状态行
        std::vector<std::string> response_header; // 响应报头
        std::string blank; //空行
        std::string response_body; //响应正文

        int status_code;
        int fd;
    public:
        HttpResponse():blank(LINE_END),status_code(OK),fd(-1){}
        ~HttpResponse(){}
};

4.4.3 部署CallBack类

进行一层封装,主要包括接收请求,构建响应,发送响应,这三个大的模板再分别调用EndPoint类里面的成员函数。

class CallBack{
    public:
        CallBack()
        {}
        void operator()(int sock)
        {
            HandlerRequest(sock);
        }
        void HandlerRequest(int sock)
        {
            LOG(INFO, "Hander Request Begin");

            EndPoint *ep = new EndPoint(sock);
            ep->RecvHttpRequest(); //1. 接收请求
            if(!ep->IsStop()){ //一定要注意逻辑关系
                LOG(INFO, "Recv No Error, Begin Build And Send");
                ep->BuildHttpResponse();//2. 构建响应
                ep->SendHttpResponse(); //3. 发送响应
            }
            else{
                LOG(WARNING, "Recv Error, Stop Build And Send");
            }
            delete ep;

            LOG(INFO, "Hander Request End");
        }
        ~CallBack()
        {}
};

4.4.4 部署EndPoint类

主要是将 接收请求,构建响应,发送响应 这三个大的模块拆分成更细粒度的接口
这部分代码内容太多,主要如下,具体代码已上传gitee;

 主要包括以下几个方面:

(1)接收请求:

  1. 接收请求行
  2. 接收请求报头
  3. 分析请求行
  4. 分析请求报头
  5. 判断是否需要接收请求正文
  6. 接收请求正文

(2)构建响应

  1. 构建响应的状态行
  2. 构建状态码为ok时候的响应报头
  3. 构建状态码为404时候的响应报头,以及打开返回错误网页的文件
  4. 构建响应正文
    非CGI模式下,打开request所需资源的的文件待发送即可;
    CGI模式下(本项目的重难点),创建子进程、进程替换到cgi子程序,进程间通信(GET方法用导入环境变量,POST方法用匿名管道),数据发送给子程序,子程序返回result并填充response_body;

(3)发送响应

  1. 发送响应的状态行
  2. 发送响应报头
  3. 发送空行
  4. 发送响应正文
    CGI模式下(本项目的重难点),response_body已经被填充,发送response_body里面的内容即可;
    非CGI模式下(本项目的重难点),发送响应正文,不需填充response_body,直接利用sendfile发送相应的资源即可(包括GET方法不带参返回静态网页、发生错误时返回错误页面);

下面介绍本部分一些重难点的实现:

(1)CGI模式:

        cgi程序由我们自己进行编写,可以使用任何语言,我们只需要调用该程序处理请求即可。如何用一个程序抵用另一个程序,这对大家来说应该是不陌生的——程序替换,我们可以通过创建子进程,如何让子进程进行程序替换,去执行对应的cgi程序,为了让cgi程序能够将请求处理结果返回个父进程,这里需要让父子进程进行通信。进程间通信的方式有很多种,我们这里选择使用匿名管道,因为管道通信时单向的,因为需要双向通信,所以这里采用创建两个匿名管道的方法进行双向通信,创建两个管道:out[2],in[2],父进程使用out[1]作为写端,in[0]作为读端,子进程使用out[0]作为读端,in[1]作为写端,如下:

父进程想cgi传参可以有两种:往管道里写和导环境变量。如果是GET请求,因为参数是比较短的,所以这里我们可以采取导环境变量的方式;如果是POST请求,因为POST的参数在正文中,正文相比GET命令行参数肯定会大很多,所以这里采用往管道里写的方式传参,这里还需要导入Content-Length的大小,导进环境变量,让cgi能够得知。同时我们需要让cgI知道请求方法是什么,所以这个也同样需要通过导环境变量的方式让cgi能够读取到,所以总结如下:

  • GET:需导环境变量METHODQUERY_STRING
  • POST:正文从管道写,需导环境变量METHODCONTENT_LENGTH

这里还有一个问题:cgi如何得知管道的读端和写端是多少?
        程序替换后,进程的代码和数据会进行替换,但进程的数据结构是不变的。子进程的文件描述符表和替换前是一样的,这些是都不变的,所以这里我们可以在程序替换前,将子进程的管道读端和写端进行重定向,把子进程的读端重定向到标准输入,写端重定向到标准输出中,这样程序替换后,cgi只需要用标准输入和标准输出进行读写管道即可,整个cgi布局如下图:

 

        综上,可以看出的是,cgi程序本质上是使用标准输入和标准输出与浏览器进行交互,完全可以忽略中间一整套通信细节。所以以后的web开发,也就是开发cgi程序。

(2)sendfile函数的用法

发送响应正文时应注意:

  • 如果启动了cgi机制,正文内容就放在了response_body中,直接发送response_body即可;
  • 如果不是cgi机制,就需要传送资源文件给客户端,如果是用write和 read将文件先读出来,再写入客户端套接字中,需要经过用户层,sendfile这个接口可以在内核层完成一个文件到一个文件的拷贝,不经过用户层,效率比前者高,如下:

sendfile函数的用法:

函数原型:

#include <sys/sendfile.h> 
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);

功能: 把一个文件描述符的内容拷贝给另一个文件描述符,在内核层完成该操作,不经过用户层,比read和write的效率高

参数:

  • out_fd: 要被写入的文件描述符
  • in_fd: 要被读取的文件描述符
  • offset: 偏移量,可以记录读取文件的位置
  • count: 要拷贝内容的大小

返回值: 成功返回已经写入的字节数,失败返回-1

4.5 部署CGI子程序

4.5.1 网络计算器

该cgi程序处理两个参数的请求,并且算出加减乘除的结果,以html文本进行返回。

int main()
{
    std::string query_string;
    GetQueryString(query_string);
    //a=100&b=200
    
    std::string str1;
    std::string str2;
    CutString(query_string, "&", str1, str2);

    std::string name1;
    std::string value1;
    CutString(str1, "=", name1, value1);

    std::string name2;
    std::string value2;
    CutString(str2, "=", name2, value2);

    //1 -> 
    std::cout << name1 << " : " << value1 << std::endl;
    std::cout << name2 << " : " << value2 << std::endl;

    //2
    std::cerr << name1 << " : " << value1 << std::endl;
    std::cerr << name2 << " : " << value2 << std::endl;
    double x = atoi(value1.c_str());
    double y = atoi(value2.c_str());

    //可能向进行某种计算(计算,搜索,登陆等),想进行某种存储(注册)
    std::cout << "<html>";
    std::cout << "<head><meta charset=\"utf-8\"></head>";
    std::cout << "<body>";
    std::cout << "<h3> " << value1 << " + " << value2 << " = "<< x+y << "</h3>";
    std::cout << "<h3> " << value1 << " - " << value2 << " = "<< x-y << "</h3>";
    std::cout << "<h3> " << value1 << " * " << value2 << " = "<< x*y << "</h3>";
    std::cout << "<h3> " << value1 << " / " << value2 << " = "<< x/y << "</h3>";
    std::cout << "</body>";
    std::cout << "</html>";
    return 0;
}

4.5.2 用户注册信息上传到数据库

本项目设计一个mysql_cgi程序,该程序连接MySQL,可以将用户注册的信息进行存储和查询。

bool InsertSql(std::string sql)//链接数据库并插入用户注册信息
{
    MYSQL *conn = mysql_init(nullptr);
    mysql_set_character_set(conn, "utf8");
    
    //数据库用户名http_test的密码是12345678
    if(nullptr == mysql_real_connect(conn, "127.0.0.1", "http_test", "******", "http_test", 3306, nullptr, 0)){
        std::cerr << "connect error!" << std::endl;
        return 1;
    }
    std::cerr << "connect success" << std::endl;

    std::cerr << "query : " << sql <<std::endl;
    int ret = mysql_query(conn, sql.c_str());
    std::cerr << "result: " << ret << std::endl;
    mysql_close(conn);
    return true;
}

int main()
{
    std::string query_string;
    //from http
    if(GetQueryString(query_string)){
        std::cerr << query_string << std::endl;
        //数据处理?
        //name=tom&passwd=111111
        std::string name;
        std::string passwd;
        CutString(query_string, "&", name, passwd);
        std::string _name;
        std::string sql_name;
        CutString(name, "=", _name, sql_name);
        std::string _passwd;
        std::string sql_passwd;
        CutString(passwd, "=", _passwd, sql_passwd);
        std::string sql = "insert into user(name, passwd) values (\'";
        sql += sql_name;
        sql += "\',\'";
        sql += sql_passwd;
        sql += "\')";

        //插入数据库
        if(InsertSql(sql)){
            std::cout << "<html>";
            std::cout << "<head><meta charset=\"utf-8\"></head>";
            std::cout << "<body><h1>注册成功!</h1></body>";
        }
    }
    return 0;
}

4.6 日志类

时间戳可以通过time库函数进行获取,错误文件名称和错误行分别通过可通过分别通过__FILE____LINE__两个宏进行获取,于是就可以写出一个日志函数。
每次调用传四个参数会显得比较麻烦,且后面两个参数是比较固定的,所以为了方便,这里采用一个宏来封装该函数。

#define INFO    1
#define WARNING 2
#define ERROR   3
#define FATAL   4

#define LOG(level, message) Log(#level, message, __FILE__, __LINE__)//后面两个参数会自动获取

//引用LOG函数时候只需要填充前两个参数
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;
}

4.7 Util类

工具类主要提供了一些分析协议时会用到的字符串处理的方法

4.7.1 按行读取

我们都知道,HTTP协议用行的方式来陈列协议内容,其中不同的浏览器下行分隔符的表示方式是不一样的,一般有下面三种:\r\n ,\r , \n
所以为了方便分析协议,我们可以在读取协议的每一行时候都将其行分隔符进行统一处理,统一转为\n的形式。所以这里设计了一个ReadLine的方法进行处理。

  1. 该函数从sock中读取一行协议内容,然后将行分割符进行处理,然后返回,所以这里使用两个参数:sock、out转换之后的一行)
  2. 逐个读取字符,如果不是\r和\n,就直接将该字符加入out中。如果此时是\r,那么改行分隔符可能是\r或·\r\n,所以接下来读取的字符可能是\n或下一行的其它字符,所以此时需要根据下一个字符判断是哪一种情况,如果此时直接使用recv读取下一个字符,会将缓冲区的字符拷贝到上层,这样对下一次读取一行很不利。能不能放回去能?这是一个很麻烦的事情,所以有没有一种方法能够让只查看下一个字符而不拷走的方法呢?答案是有的,我们可以调整recv的选项字段,选择MSG_PEEK选项,只读不拷走下一个字符,所以这里我们选择使用MSG_PEEK选项进行窥探
    如果下一个字符为\n,代表该协议的行分隔符是\r\n类型的,所以我们将该字符读走,否则我们直接把要添加的字符改成\n
  3. 最后处理完上面两种情况之后,接下来检验ch这个字符,如果是\n,就将该字符添加至out,并停止读取,返回out的大小
		//逐行读取工具
        static int ReadLine(int sock, std::string &out)
        {
            char ch = 'X';
            while(ch != '\n'){
                ssize_t s = recv(sock, &ch, 1, 0);
                if(s > 0){
                    if(ch == '\r'){
                        recv(sock, &ch, 1, MSG_PEEK);
                        if(ch == '\n'){
                            //把\r\n->\n
                            //窥探成功,这个字符一定存在
                            recv(sock, &ch, 1, 0);
                        }
                        else{
                            ch = '\n';
                        }
                    }
                    //1. 普通字符
                    //2. \n
                    out.push_back(ch);
                }
                else if(s == 0){
                    return 0;
                }
                else{
                    return -1;
                }
            }
            return out.size();
        }

4.7.2 字符串切分

我们都知道,HTTP报头中的信息是以key:value的方式行陈列出来的,所以我们需要将其进行解析,分割成两个字符串。所以这里实现了一个简单的字符串分割方法:

        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 != std::string::npos){
                sub1_out = target.substr(0, pos);
                sub2_out = target.substr(pos+sep.size());
                return true;
            }
            return false;
        }

5. 项目测试:

5.1 发布版本功能测试

直接在浏览器中输入url进行功能测试,详见之前的项目演示。

5.2 抓包测试

分别使用telnet工具和postman进行测试。

5.2.1 telnet工具

(1)GET方法测试,不带参数,返回默认首页信息index.html

  • 依次执行如下命令:
  • telnet 127.0.0.1 8081+回车
  • ctrl+]+回车
  • GET / http/1.0+回车+回车

 (2)GET方法测试,带参数,返回test_cgi子程序执行结果

  • 依次执行如下命令:
  • telnet 127.0.0.1 8081+回车
  • ctrl+]+回车
  • GET /test_cgi?a=10&b=20 http/1.0+回车+回车

(3)POST方法测试,返回test_cgi子程序执行结果

  • 依次执行如下命令:
  • telnet 127.0.0.1 8081+回车
  • ctrl+]+回车
  • POST /tets_cgi http/1.0+回车
  • Content-Length: 7+回车+回车
  • a=2&b=5+回车

5.2.2 postman工具

(1)GET方法测试,不带参数,返回默认首页信息index.html

(2)GET方法测试,带参数,返回test_cgi子程序执行结果

(3)POST方法测试,返回test_cgi子程序执行结果
其中参数在正文(request_body)中

6. 项目总结:

遇到的一些问题:

  1. 读取协议报头的行分隔符需要做统一处理,在判断行分隔符是\r还是\r\n时,不能够直接调用recv继续读取下一个字符,否则会将接受缓冲区的字符拿走,这时候需要使用MSG_PEEK选项进行窥探下一个字符,使用该选项不会将接受缓冲区的字符拿走,十分地友好;
  2. 程序替换后,进程的代码和数据会进行替换,但进程的数据结构是不变的。父进程打开的文件也不会变,但是进程替换之后子进程如何得到管道读写端的文件描述符方法一:调用cgi程序之前,需要根据不同的方法导入不同的环境变量,且还要让cgi程序知道两个管道的读端和写端的文件描述符;方法二:环境变量不太友好,对端不但要通过环境变量的方式获取参数,还要通过环境变量获取文件描述符,这样有点麻烦。最后想出用dup2系统调用对两个文件描述符进行重定向,重定向到标准输入和标准输出,这样cgi程序可以直接通过标准输入和标准输出进行读写管道;
  3. 发送响应正文时,如果要返回网页资源,开始想通过read先进行读文件,读到自己定义的一个缓冲区中,然后调用write将缓冲区中的内容写入sock中。这种方法感觉十分地麻烦,每次都要开一个缓冲区,开销大,效率低。后来发现sendfile这个接口可以在内核完成一个文件描述符到另一个文件描述符的拷贝,效率很高;
  4. 服务器在写入时,客户端关闭连接会导致服务器崩溃。这是因为操作系统给服务器进程发送了SIGPIPE信号,导致服务器崩溃,这个bug开始并没有考虑到,后面意识到了将该信号设置为SIG_IGN,忽略该信号,解决了问题;
  • 2
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值