文章目录
- 前言
- 一、了解HTTP协议是如何规定的
- 总结
前言
HTTP协议(超文本传输协议)和我们上一篇写的网络版计算器中自己定制的协议一样,只不过Http协议是是一个非常好用的协议,所以我们可以直接用现成的不用自己再搞一套了。
一、了解HTTP协议是如何规定的
如下图所示,HTTP协议包含以下几个部分:
现在我们随便看一下http网址,几乎都没有登录信息了,因为这个东西不再被需要了。为什么http要有文件路径呢?因为我们的网页资源实际上是从服务器的某个文件拿的,这也就解释了为什么http协议被称为超文本传输协议。
urlencode和urldecode:
wd就是我们 输入的关键字,可以看到c++中的+号被转移为“%2B”,而urldecode就是urlencode的逆过程。
首先一般情况下对于以上编码解码的过程是不需要我们做这个工作的,即使需要我们做这个工作也可以直接从网络拿取写好的编码解码代码。下面我们认识一下http版本的请求和回应都有哪几部分组成:
我们http请求包括请求行,请求报头,空行和请求正文。在请求行中,我们分为三部分,get是http获取的方法,主要有get和post方法。url就是域名部分,后面httpversion就是我们http的版本号,注意后面有\r\n:
比如上面红色部分就是url了。了解了请求行后我们再看请求报头,每一个请求报头都由\r\n作为结尾。请求报头过来是空行,空行就是\r\n,然后是请求正文,请求正文是可以没有的这点要注意。
在响应部分有状态行,响应报头,空行,响应正文。状态行中分为三部分,第一部分是HTTP协议版本号,第二部分是状态码,比如我们经常遇到的404就是状态码,第三部分是状态码描述,状态码描述就比如404后面跟着“访问的资源不存在”这句话。
下面我们来关注两个细节:
在http中,如何保证请求和响应应用层完整读取完毕了呢?其实很简单,我们会发现所有http请求的字段都是字符串,并且以行为单位,所以要保证完整读取就只需要用while循环按行为单位将所有请求行和请求报头读取完毕即可,注意我们第三部分就是空行,这就是while循环的判断条件,只要读到空行,说明我们将请求行和请求报头都读取完毕了,那么如何保证读取正文呢,还记得我们实现网络版计算器自定协议为正文长度+\r\n+正文+\r\n吗,没错http中报头前面也是正文的长度,只要我们读取完头就知道了正文长度,从而将正文读取完毕。
第二个细节, 请求和响应是如何做到序列化和反序列化的?这里是http自己实现的,序列化只需要将请求报头按照\r\n依次插入到请求行的后面即可,反序列化直接按照\r\n将整个字符串拆解为多个字符串即可。对于正文是不用做处理的,这是http协议规定的。
下面我们自己构建一个简单的http服务器:
首先服务器还是之前Tcp的代码,我们修改一下即可:
namespace server
{
enum
{
SOCKET_ERR = 2,
USE_ERR,
BIND_ERR,
LISTEN_ERR
};
static const uint16_t gport = 8080;
//listen的第二个参数是底层全连接长度+1
static const int gbacklog = 5;
class HttpServer
{
public:
HttpServer(func_t func,const uint16_t& port = gport)
:_port(port)
,_listensock(-1)
,_func(func)
{
}
void initServer()
{
//1.创建文件套接字对象
_listensock = socket(AF_INET,SOCK_STREAM,0);
if (_listensock==-1)
{
exit(SOCKET_ERR);
}
//2.进行bind
struct sockaddr_in local;
bzero(&local,sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(_port);
local.sin_addr.s_addr = INADDR_ANY; //INADDR_ANY绑定任意地址IP
if (bind(_listensock,(struct sockaddr*)&local,sizeof(local))<0)
{
exit(BIND_ERR);
}
//3.Tcp需要将套接字状态设为listen状态来一直监听(因为Tcp是面向字节流的)
if (listen(_listensock,gbacklog)<0)
{
exit(LISTEN_ERR);
}
}
void start()
{
//忽略17号信号
signal(SIGCHLD,SIG_IGN);
for (;;)
{
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
int sock = accept(_listensock,(struct sockaddr*)&peer,&len);
if (sock<0)
{
continue;
}
cout<<"sock: "<<sock<<endl;
pid_t id = fork();
if (id==0)
{
close(_listensock);
close(sock);
exit(0);
}
close(sock);
}
}
~HttpServer()
{
}
private:
int _listensock; //不是用来进行数据通信的,它是用来监听链接到来获取新链接的
uint16_t _port;
func_t _func;
};
}
因为我们是没有写对客户端的请求做处理的回调函数的,所以我们先写一个回调函数:
因为回调函数是需要客户端请求处理后将结果返回到响应的,所以还需要一个类来保存请求与响应:
class HttpRequest
{
public:
HttpRequest()
{}
~HttpRequest()
{}
public:
string inbuffer;
};
class HttpResponse
{
public:
string outbuffer;
};
目前我们就仅在这两个类中放一个string缓冲区即可,然后我们用包装器定义一个回调函数:
注意在服务器的私有成员变量中加一个回调函数,然后我们在启动服务器的时候处理这个请求:
下面我们编写一下处理客户端请求的函数:
注意:我们编写网络版计算器的时候,那个时候接收客户端消息需要自己去掉报头然后反序列化,而今天的实现我们就不去做那些事情,因为我们只是演示一下http服务器的原理,对于协议就不再浪费时间了:
void HanderHttp(int sock)
{
//1.读到完整的数据请求
// ..........
HttpRequest req;
HttpResponse resp;
char buffer[4096];
ssize_t n = recv(sock,buffer,sizeof(buffer)-1,0);
if (n>0)
{
buffer[n] = 0;
req.inbuffer = buffer;
_func(req,resp);
send(sock,resp.outbuffer.c_str(),resp.outbuffer.size(),0);
}
}
我们今天要做的只需要构建请求与响应对象,然后定义一个缓冲区将文件描述符的数据读到缓冲区中,如果读取成功我们就将缓冲区的数据放到请求对象的缓冲区中,然后用回调函数对客户端的请求做处理,处理完发送回客户端即可。
有了hander方法我们就可以实现一下.cc文件:
void Usage(string proc)
{
cout<<"Usage: \n\t"<<proc<<" port\r\n\r\n";
}
// 1.服务器和网页分离,html
// 2.url -> / :web根目录
bool Get(const HttpRequest& req,HttpResponse& resp)
{
cout<<"------------------http start-----------------------"<<endl;
cout<<req.inbuffer<<endl;
cout<<"------------------http end-----------------------"<<endl;
return true;
}
int main(int argc,char* argv[])
{
if (argc!=2)
{
Usage(argv[0]);
exit(0);
}
uint16_t port = atoi(argv[1]);
unique_ptr<HttpServer> hps(new HttpServer(Get,port));
hps->initServer();
hps->start();
return 0;
}
对于get方法我们就先演示一下,等会再添加内容:
下面我们运行起来:
运行起来后我们该如何访问呢,只需要在网址栏填入你的云服务器ip地址和端口号,注意ip地址和端口号以英文冒号连接:
虽然我们的网页什么都没有,但是服务端会显示哪个浏览器访问我们服务端的记录,下面我们解释一下每一行的意思:
第一个get表示浏览器请求的方法,默认的方法就是get方法。get后面的/就是url,因为我们登录浏览器的时候只是告诉浏览器去哪个ip和端口,并没有添加路径告诉浏览器我们要请求哪个资源,所以默认是个根目录,注意没有请求指定的资源,默认返回服务器首页,但是我们没有做处理所以是根目录。第三个HTTP1.1就是目前主流的http版本。第二行的host代表什么呢?host代表我们的请求是要发送给哪个服务端的,其实就是我们刚开始输入网址的ip和端口号,为什么会有这个呢?因为有代理服务器的存在。第三行代表支持长连接,第四行代表协议升级,也就是说http协议是可以被升级的。第五行就很有意思了,比如你用手机浏览器登录这个网址,那么上面就会显示你手机的信息,如下图:
看到了这个我们就不难理解为什么我们用苹果手机和安卓手机搜索一个应用程序时,苹果手机浏览器会默认把ios版APP放到首页,因为当我们请求某个服务器时我们用什么设备请求的信息也会被服务器拿到。第六行代表客户端能接受什么样的格式,就比如文档格式,第七行表示客户端支持压缩。
因为上面中是没有服务器对于客户端请求的响应的,所以下面我们设计一下响应,让服务端给客户端发一个我们设定好的状态行,除了状态行还有空行和正文,对于响应报头我们就先不写。
bool Get(const HttpRequest& req,HttpResponse& resp)
{
cout<<"------------------http start-----------------------"<<endl;
cout<<req.inbuffer<<endl;
cout<<"------------------http end-----------------------"<<endl;
string respline = "HTTP/1.1 200 OK\r\n"; //响应行
string respblank = "\r\n"; //空行 先暂时不写响应报头
string body = "<html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>my web</title><h1>hello this is test</h1></head><body><p>给你科普一下鸭子的种类、达克鸭、小黄鸭、扁嘴鸭、我想你了鸭</p></body></html>";
resp.outbuffer+=respline;
resp.outbuffer+=respblank;
resp.outbuffer+=body;
return true;
}
首先说一下这样做的原理,因为未来读到客户端的请求我们会调用get函数,而我们上面是直接将resp填充了一下,在响应中包含响应行,空行和正文,回调函数结束会将这个填充好的响应发送到客户端,下面我们运行起来看看:
可以看到我们访问这个服务器是可以看到响应的,下面我们看看访问信息:
可以看到是没有问题的,这里我们说一下:实际上我们的浏览器已经非常智能了,我们刚刚没有报头的信息没有正文的长度,浏览器依旧可以识别,但是今天我们只是为了做演示,如果真的要实现这些要做的工作我们还是需要做的。
下面我们就将报头加入进来:
bool Get(const HttpRequest& req,HttpResponse& resp)
{
cout<<"------------------http start-----------------------"<<endl;
cout<<req.inbuffer<<endl;
cout<<"------------------http end-----------------------"<<endl;
string respline = "HTTP/1.1 200 OK\r\n"; //响应行
string respheader = "Content-type: text/html\r\n"; //响应报头
string respblank = "\r\n"; //空行
string body = "<html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>my web</title><h1>hello this is test</h1></head><body><p>给你科普一下鸭子的种类、达克鸭、小黄鸭、扁嘴鸭、我想你了鸭</p></body></html>";
resp.outbuffer+=respline;
resp.outbuffer+=respheader;
resp.outbuffer+=respblank;
resp.outbuffer+=body;
return true;
}
content-type代表我们有效载荷的类型,通过content-type对照表我们可以告诉客户端我们返回的是什么资源,比如照片就是jpg等类型,然后我们在响应的缓冲区中加上响应报头。我们今天演示就以html为例:
网页资源对应的就是text/html,下面我们运行起来:
我们通过telnet工具可以看到响应的信息都有了。前面我们说了,当我们直接以ip+端口号的方式连接服务器时,默认资源路径是根目录,那么今天我们想访问其他其他资源该怎么做呢:
我们发现当用路径去访问服务器的某个资源的时候,浏览器给服务器发送的url中自动在根目录的后面加了我们要访问资源的路径,下面我们实现一下这个操作并且解决两个问题1.服务器和网页分离2.url是一个/的时候表示web根目录,我们也实现一下这个根目录。
首先要将服务器和网页分离,那么就需要对url做切分,所以我们再创建一个新文件用来处理字符串的切分:
在处理url之前我们还要对请求行做拆分,这样才能拿到url,所以我们在请求类中在多加几个成员变量分别代表请求行,请求方法,url和httpversion.
const string sep = "\r\n";
class HttpRequest
{
public:
HttpRequest()
{}
~HttpRequest()
{}
void parse()
{
// 1.从inbuffer中拿到第一行,分隔符\r\n
string line = Util::getOneLine(inbuffer,sep);
if (line.empty())
{
return;
}
//2.从请求行中提取三个字段
}
public:
string inbuffer;
string method; //请求行get方法
string url; //请求行url
string httpversion; //http版本
};
在对请求行做拆分之前还要拿到请求的第一行,所以我们需要写一个函数,这个函数就放在刚刚创建的新文件中,因为要频繁的用到分隔符,所以我们直接定义了一个。
class Util
{
public:
static std::string getOneLine(std::string &buffer,const std::string& sep)
{
auto pos = buffer.find(sep);
if (pos==std::string::npos)
{
return "";
}
std::string str = buffer.substr(0,pos);
buffer.erase(0,str.size()+sep.size());
return str;
}
};
要拿到第一行还是比较简单的,我们写静态成员函数的原因是不需要this指针,如果是成员函数还会多一个this指针的参数并且我们未来可能会持续获取请求行请求报头,用静态的会更好。要获取一行首先需要一个缓冲区,然后是分隔符。这个缓冲区就是整个请求序列,有请求行请求报头什么的。我们先找第一个\r\n的位置,这样就确定了第一行,然后判断能否找到找不到就返回一个空字符串,找到了就把第一行的字符串返回并且将缓冲区第一行的字符串清空,这样我们如果要获取后面的每一行就会很方便。
void parse()
{
// 1.从inbuffer中拿到第一行,分隔符\r\n
string line = Util::getOneLine(inbuffer,sep);
if (line.empty())
{
return;
}
//2.从请求行中提取三个字段
cout<<"line: "<<line<<endl;
stringstream ss(line);
ss>>method>>url>>httpversion;
}
获取到第一行后我们先打印,然后用stringstream将以空格为分割的三个字段全部传入类内部的method和url和httpversion,这里的stringstream的工作实际上就是反序列化。
在hander方法中,我们先打印获取请求行的三个字段,然后再调用回调函数处理。
bool Get(const HttpRequest& req,HttpResponse& resp)
{
cout<<"------------------http start-----------------------"<<endl;
cout<<req.inbuffer<<endl;
cout<<"method: "<<req.method<<endl;
cout<<"url: "<<req.url<<endl;
cout<<"httpversion: "<<req.httpversion<<endl;
cout<<"------------------http end-----------------------"<<endl;
//........................
}
然后我们把请求类中三个字段在执行get函数的时候打印出来,后面的代码都一样就用...省略了,下面我们将程序运行起来:
这样我们不就把请求行和请求行的三个字段拿出来了吗,当然我们也试试不加路径默认访问是什么样的请求:
可以看到如果不加路径默认的请求行后面是/favicon.ico,其实这个就是我们网站的一个标签而已:
如上图百度额logo就是/favicon.ico。
了解了上面的知识我们再来看看web根目录,我们通过上面的图片可以看到不管服务器收到什么样的请求在url中都是以/开头的,所以我们定义默认路径的时候后面不用加/:
const string default_root = "wwwroot"; //web根目录
然后我们在vscode中也创建一个这样的目录:
接下来我们在请求行中增加一个string对象来保存路径:
void parse()
{
// 1.从inbuffer中拿到第一行,分隔符\r\n
string line = Util::getOneLine(inbuffer,sep);
if (line.empty())
{
return;
}
//2.从请求行中提取三个字段
cout<<"line: "<<line<<endl;
stringstream ss(line);
ss>>method>>url>>httpversion;
//3.添加web默认路径
path = default_root;
path+=url;
if (path[path.size()-1]=='/')
{
path+=home_page;
}
}
首先我们让路径默认是在wwwroot这个目录下,然后如果用户指定路径比如在/z/b中,那么路径就变成了/wwwroot/z/b,所以我们让path加等url,最后为什么要判断一下呢?这是因为我们的用户有可能只通过ip和端口号访问,他们不知道服务器的哪个路径有资源,所以我们应该设置一个判断如果用户没有输入路径,那么由url自动添加/的特性,我们直接判断路径字符串最后一个字符是否是/,如果是则说明用户没有填写路径这个时候我们就直接跳到主页即可,对于主页,我们也要设置一个默认文件:
一般主页的默认文件都是index.html, 这样的话如果用户没有指定访问路径,我们就在web根目录后面添加主页文件路径,这样的话就可以直接跳到主页了,所以我们还应该创建一个主页的文件:
然后我们就随便写一句话就好:
然后我们在get函数中把路径也打印出来:
下面我们运行起来看看:
当url为/的时候,路径会默认拼接变成wwwroot/index.html,当url为指定路径的时候:
通过以上的结果我们应该可以认识到web根目录是什么了,下一篇文章中我们将在web根目录中写一些具体的资源并且还会加入跳转的按钮。
总结
这篇文章中主要讲解http协议是如何实现的,以及底层的一些原理是什么,在我们手动实现一些原理的时候我们才能对http有更加深刻的认识。