文章目录
- 1、URL
- 2、http协议的宏观构成
- 3、详细理解http协议
- 1、http请求
- 2、http响应
- 1、有效载荷格式
- 2、有效载荷长度
- 3、客户端要访问的资源类型
- 4、修改响应写法
- 5、处理不同的请求
- 6、跳转
- 3、请求方法(GET/POST)
- 4、HTTP状态码(实现3和4开头的)
- 5、HTTP常见Header
- 6、http的会话保持功能(Cookie)
- 4、结束
本篇很长。我计划http和https总共两篇。
HTTP可以把网页资源,文本资源,音视频资源都拿到,HTTP叫做超文本传输协议。
客户端和服务端,两者做交互,客户端把自己的东西给别人,服务端把别人的东西拿到自己本地。系统角度,这是IO操作;网络角度,这是请求(request)回复(reponse)操作;用户角度,则有上行和下行操作,上行就是把自己东西给别人,下行拿别人的东西。
网页,图片,音视频等这些都叫资源。
1、URL
要想访问服务器,需要知道服务器的IP和端口号,但实际生活中,我们更多知道的是网站的域名,用域名去访问网站,而不是知道IP地址和端口号。虽然用域名来访问一个网站,但是会有域名解析服务,会把IP地址拿出来。
登录信息现在已经没有,//后直接接上www。之前已经知道,服务端的端口号不能随意指定,必须是众所周知且不能随意更改的,端口号和成熟的应用层协议是一一对应的,https常用的是443,http常用的是80,端口号在浏览器的底层代码中,检测哪个协议就用哪个端口号,协议名称和端口号是1对1强相关的。
到了服务器端口号时,我们就已经能访问这个网址了,但是要访问什么,得看后面带层次的文件路径,这里就是访问内容。网站内部是用Linux来创建的,斜杠就是Linux中的文件分隔符。图片中的dir是web根目录,这个根目录是web进程自己的一个目录。问号是一个分隔符,右面的是一些参数。有些写法是xx=xxx,这其实就是kv的,有多份kv就用&来分隔。井号后面的是片段标识符,这个在现在很少见,了解一下即可。
协议,域名(也就是上图中的服务器地址),端口号,资源路径,参数,这些部分就组成了URL。URL是统一资源定位符,通过URL可以访问网络中唯一一个资源(服务器地址也就是IP地址,端口号,文件路径,三个都是唯一的)。URL是我们访问网络的一个超链接。
如果搜索问号,井号,斜杠这些特殊字符,浏览器会把它们都转换成别的样式,这是url的encode编码,解决在url中出现特殊符号。比如百度搜索中,会在查询字符串wd=后面加上搜索的东西。服务端收到的就是这些经过处理后的我们输入的要搜索的东西,得到这些特殊符号后再转化回来,这就是decode操作。
不止问号,井号,还有一些符号也会做处理,比如汉字,url有自己的转码方法。
2、http协议的宏观构成
http协议是基于TCP套接字的应用层的协议。http由4部分构成(也有分成2层的,这里是全写出来)
报头的形式就是Key: Value,后面跟回车,请求报头就是多行KV结构组成的。 空行能够区分上部分和下部分。请求行和请求报头可以说成报头部分,有效载荷则是http协议的有效载荷。对于报头和载荷,http读到空行就认为是报头结束了,因为报头是多行的,读完之后就是空行部分,然后再开始载荷,这也就分离了报头和载荷;序列化反序列化就像之前所写的,序列化是把所有消息,所有请求都放到一行发送过去,反序列化则按照\r\n分出来多行。
上面是请求的http协议结构,接收请求,也就是响应的结构一样,只是名字变了,从上到下为状态行、响应报头、空行、有效载荷(各种资源,比如html/css,图片,音频、视频等)。状态行里包含协议版本、状态码、状态码描述,协议版本和请求的那个版本一样,状态码是一个数字,描述则是状态码对应的状态,比如状态码404。接收的http协议也是读到空行就认为读完了报头,就可以把报头和载荷分开。
请求的部分中的协议版本是客户端版本,接收的版本则是服务端版本,比如微信,有一些用户会不升级微信,而服务端那里已经升级了,这就出现了版本不对应的情况。为了解决这个问题,在进行请求之前,就会先检验客户端的版本,服务端暴露给客户端对应版本的http。所以服务端不是只提供最新版本的,而是客户端什么版本服务端就提供什么版本。
3、详细理解http协议
1、http请求
通过代码来向浏览器发送请求。用到上一篇中网络计算器中的err.hpp和Sock.hpp和log.hpp,Http_v1目录内创建以下文件:
err.hpp
#pragma onceenum
{USAGE_ERR = 1,SOCKET_ERR,BIND_ERR,LISTEN_ERR,CONNECT_ERR,SETSID_ERR,OPEN_ERR
};
log.hpp
#pragma once#include <iostream>
#include <cstdio>
#include <cstring>
#include <string>
#include <cstdarg>
#include <ctime>
#include <sys/types.h>
#include <unistd.h>const std::string filename0 = "log/tcpserver.log.Debug";
const std::string filename1 = "log/tcpserver.log.Info";
const std::string filename2 = "log/tcpserver.log.Warning";
const std::string filename3 = "log/tcpserver.log.Error";
const std::string filename4 = "log/tcpserver.log.Fatal";
const std::string filename5 = "log/tcpserver.log.Unknown";enum
{Debug = 0,//调试信息Info,//正常信息Warning,//告警,不影响运行Error,//一般错误Fatal,//严重错误Unknown
};static std::string toLevelString(int level, std::string& filename)
{switch(level){case Debug:filename = filename0;return "Debug";case Info:filename = filename1;return "Info";case Warning:filename = filename2;return "Warning";case Error:filename = filename3;return "Error";case Fatal:filename = filename4;return "Fatal";default:filename = filename5;return "Unknown";}
}static std::string getTime()
{time_t curr = time(nullptr);//拿到当前时间struct tm *tmp = localtime(&curr);//这个结构体有对于时间单位的int变量char buffer[128];snprintf(buffer, sizeof(buffer), "%d-%d-%d %d:%d:%d", tmp->tm_year + 1900, tmp->tm_mon + 1, tmp->tm_mday, \tmp->tm_hour, tmp->tm_min, tmp->tm_sec);//这些tm_的变量就是结构体中自带的,tm_year是从1900年开始算的,所以+1900return buffer;
}//日志格式: 日志等级 时间 pid 消息体
//logMessage(DEBUG, "hello: %d, %s", 12, s.c_str()); 12以%d形式打印, s.c_str()以%s形式打印
void logMessage(int level, const char* format, ...)//...就是可变参数,format是输出格式
{//写入到两个缓冲区中char logLeft[1024];//用来显示日志等级,时间,pidstd::string filename;std::string level_string = toLevelString(level, filename);std::string curr_time = getTime();snprintf(logLeft, sizeof(logLeft), "[%s] [%s] [%d] ", level_string.c_str(), curr_time.c_str(), getpid());char logRight[1024];//用来显示消息体va_list p;va_start(p, format);//直接用这个接口来对format进行操作,提取信息vsnprintf(logRight, sizeof(logRight), format, p);va_end(p);//打印printf("%s%s\n", logLeft, logRight);//format是一个字符串,里面有格式,比如%d, %c,通过这个就可以用arg来提取参数//保存到文件中FILE* fp = fopen(filename.c_str(), "a");if(fp == nullptr) return ;fprintf(fp, "%s%s\n", logLeft, logRight);fflush(fp);fclose(fp);//va_list p;//char*//下面是三个宏函数//int a = va_arg(p, int);//根据类型提取参数//va_start(p, format);//让p指向可变参数部分的起始地址//va_end(p);//把p置为空, p = NULL
}
Sock.hpp
#pragma once
#include <iostream>
#include <string>
#include <cstdlib>
#include <cstring>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <unistd.h>
#include "err.hpp"
#include "log.hpp"static const int gbacklog = 32;
static const int defaultfd = -1;class Sock
{
public:Sock(): _sock(defaultfd){}void Socket(){_sock= socket(AF_INET, SOCK_STREAM, 0);if(_sock < 0){logMessage(Fatal, "socket error, code: %d, errstring: %s", errno, strerror(errno));exit(SOCKET_ERR);}}void Bind(const uint16_t& port){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;if(bind(_sock, (struct sockaddr*)&local, sizeof(local)) < 0){logMessage(Fatal, "bind error, code: %d, errstring: %s", errno, strerror(errno));exit(BIND_ERR);}}void Listen(){if(listen(_sock, gbacklog) < 0)//第二个参数维护了一个队列,发送了连接请求但是服务端没有处理的客户端,服务端开始accept后,就会出现另一个队列,就是服务端接受了请求但还没被accept的客户端{logMessage(Fatal, "listen error, code: %d, errstring: %s", errno, strerror(errno));exit(LISTEN_ERR);}}int Accept(std::string* clientip, uint16_t* clientport){struct sockaddr_in temp;socklen_t len = sizeof(temp);int sock = accept(_sock, (struct sockaddr*)&temp, &len);if(sock < 0){logMessage(Warning, "accept error, code: %d, errstring: %s", errno, strerror(errno));}else{*clientip = inet_ntoa(temp.sin_addr);//这个函数就可以从结构体中拿出ip地址,转换好后返回*clientport = ntohs(temp.sin_port);}return sock;}int Connect(const std::string& serverip, const uint16_t& serverport)//让别的客户端来连接服务端{struct sockaddr_in server;memset(&server, 0, sizeof(server));server.sin_family = AF_INET;server.sin_port = htons(serverport);server.sin_addr.s_addr = inet_addr(serverip.c_str());return connect(_sock, (struct sockaddr*)&server, sizeof(server));//先不打印消息}int Fd(){return _sock;}void Close(){if(_sock != defaultfd) close(_sock);}~Sock(){}
private:int _sock;
};
makefile
httpserver:main.ccg++ -o $@ $^ -std=c++11 -lpthread
.PHONY:clean
clean:rm -f httpserver
main.cc
#include "HttpServer.hpp"
#include <memory>int main()
{uint16_t port = 8888;std::unique_ptr<HttpServer> tsvr(new HttpServer(port));tsvr->InitServer();tsvr->Start();return 0;
}
HttpServer.hpp
#pragma once#include <iostream>
#include <string>static const uint16_t defaultport = 8888;class HttpServer
{
public:HttpServer(int port = defaultport):port_(port){}void InitServer(){;}void Start(){;}~HttpServer() {}
private:int port_;
};
接下来再继续写具体的实现,HttpServer.hpp文件中,说明在注释中
#pragma once#include <iostream>
#include <string>
#include <pthread.h>//在Start中加入多线程
#include <functional>//线程执行函数中要用到
#include "Sock.hpp"//加入套接字static const uint16_t defaultport = 8888;class HttpServer;class ThreadData//线程中使用的数据类型
{
public:ThreadData(int sock, const std::string ip, const uint16_t port, HttpServer* tsvrp):_sock(sock), _ip(ip), _port(port), _tsvrp(tsvrp){}~ThreadData() {}
public:int _sock;std::string _ip;uint16_t _port;HttpServer* _tsvrp;
};class HttpServer
{
public:HttpServer(int port = defaultport):port_(port){}void InitServer(){listensock_.Socket();listensock_.Bind(port_);listensock_.Listen();}static void* threadRoutine(void* args)//线程执行的函数,这里就是要对http进行处理,可以通过获取的套接字进行读写{pthread_detach(pthread_self());ThreadData* td = static_cast<ThreadData*>(args);//安全的强转类型}void Start(){for( ; ; )//处理请求{std::string clientip;uint16_t clientport;int sock = listensock_.Accept(&clientip, &clientport);//获取客户端链接if(sock < 0) continue;pthread_t tid;ThreadData* td = new ThreadData(sock, clientip, clientport, this);//需要传当前对象this,否则无法正常执行pthread_create(&tid, nullptr, threadRoutine, td);}}~HttpServer() {}
private:int port_;Sock listensock_;
};
这是已经做好了准备工作。开始处理
#pragma once#include <iostream>
#include <string>
#include <pthread.h>//在Start中加入多线程
#include <functional>//线程执行函数中要用到
#include "Sock.hpp"//加入套接字static const uint16_t defaultport = 8888;
class HttpServer;using func_t = std::function<std::string(const std::string&)>;//定义了一个参数对象,返回值是string类型,参数时string&,放到成员里class ThreadData//线程中使用的数据类型
{
public:ThreadData(int sock, const std::string ip, const uint16_t port, HttpServer* tsvrp):_sock(sock), _ip(ip), _port(port), _tsvrp(tsvrp){}~ThreadData() {}
public:int _sock;std::string _ip;uint16_t _port;HttpServer* _tsvrp;
};class HttpServer
{
public:HttpServer(func_t f, int port = defaultport) :func(f), port_(port){}void InitServer(){listensock_.Socket();listensock_.Bind(port_);listensock_.Listen();}void HandlerHttpRequest(int sock){char buffer[4096];std::string request;//我们认为只要一次就能读完ssize_t s = recv(sock, buffer, sizeof(buffer) - 1, 0);//-1是因为char类型,会读到\0,-1就把\0去掉if(s > 0){buffer[s] = 0;request = buffer;//处理报头std::string response = func(request);send(sock, response.c_str(), response.size(), 0);//往套接字里发送,发给客户端} else logMessage(Info, "client quit...");}static void* threadRoutine(void* args)//线程执行的函数,这里就是要对http进行处理,可以通过获取的套接字进行读写{pthread_detach(pthread_self());ThreadData* td = static_cast<ThreadData*>(args);//安全的强转类型//处理--读写td->_tsvrp->HandlerHttpRequest(td->_sock);close(td->_sock);delete td;return nullptr;}void Start(){for( ; ; )//处理请求{std::string clientip;uint16_t clientport;int sock = listensock_.Accept(&clientip, &clientport);//获取客户端链接if(sock < 0) continue;pthread_t tid;ThreadData* td = new ThreadData(sock, clientip, clientport, this);//需要传当前对象this,否则无法正常执行pthread_create(&tid, nullptr, threadRoutine, td);}}~HttpServer() {}
private:int port_;Sock listensock_;func_t func;
};
所以在这里:std::string response = func(request); 上层调用完函数后,也就是处理请求后会返回结果,给到response,然后走send接口。那么上层的调用就写在main.cc中
#include "HttpServer.hpp"
#include <memory>std::string HandlerHttp(const std::string& request)
{//这里就已经默认request是一个完整的http请求报文//返回的是一个http reponsestd::cout << "-------------------------------------" << std::endl;std::cout << request << std::endl;return "";
}int main()
{uint16_t port = 8888;std::unique_ptr<HttpServer> tsvr(new HttpServer(HandlerHttp, port));tsvr->InitServer();tsvr->Start();return 0;
}
即使返回的是空,send也可以发送。
写到这里,就可以做出实际操作了。make后,./httpserver运行起来,打开浏览器,最上方输入服务端ip地址:8888,就会发送一次http请求。不过别人那里不会有什么东西,因为我们自己还没给响应。
看一个例子
可以看到都是… : …的形式,也就是上面所写的请求报头的形式,因为main.cc的打印语句本身就有换行,所以所有报头加上一个空行才是整体的请求报头,接着下面还有一个空行,也就是结构中的空行,图中显示不明显。
第一行是请求行,有请求方法GET,URL /,以及协议版本HTTP/1.1,因为刚才用的只是ip地址和端口号,没有写要访问的路径,所以URL就只有/,如果我们要打开的网页是ip地址:端口号/a/b/c.html,那么请求行中就会显示GET /a/b/c.html HTTP/1.1,这意味着是有一个客户端想访问服务端的c.html文件,路径是/a/b/c.html。
下面的是kv结构的请求报头,Host可以直接看出来,是服务端的ip地址和端口号。Connection是这次请求的链接模式,有长链接和短链接。Cache-Control是指通信时产生的缓存的机制,表示最大缓存的生成时间,默认为0,没有缓存。Accept-Encoding表示客户端能接收的编码类型。Accept-Language表示客户端能接收的编码符号。User-Agent表示这次请求的客户端的信息。
2、http响应
telnet和postman都可以用作响应,这里不做说明。我们自己写一个简单的响应。main.cc文件中
std::string HandlerHttp(const std::string& request)
{//这里就已经默认request是一个完整的http请求报文//返回的是一个http reponse std::cout << "-------------------------------------" << std::endl;std::cout << request << std::endl;//按照响应格式来写//响应行std::string response = "HTTP/1.0 200 ok" + SEP;//固定写法,这里假设200是OK的意思//报头不写response += SEP;//空行response += "hello Http";//当作有效载荷, 也就是正文部分return response;
}
这样就可以简单响应了。
1、有效载荷格式
通常情况下,有效载荷不是一个字符串,而是网页。但不是硬编码进一个网页信息,我们需要有文件才能操作。建一个html后缀的文件
<html><body><h1>this is a test</h1></body>
</html>
在main.cc中
#include "HttpServer.hpp"
#include "err.hpp"
#include <memory>const std::string SEP = "\r\n";std::string HandlerHttp(const std::string& request)
{//这里就已经默认request是一个完整的http请求报文//返回的是一个http reponse std::cout << "-------------------------------------" << std::endl;std::cout << request << std::endl;//按照响应格式来写//响应行std::string response = "HTTP/1.0 200 ok" + SEP;//固定写法,这里假设200是OK的意思//报头不写response += SEP;//空行response += "<html> <body> <h1>this is a test</h1></body></html>";//当作有效载荷, 也就是正文部分return response;
}int main(int argc, char* argv[])//这里就在./httpserver后自己打上端口号
{if(argc != 2) exit(USAGE_ERR);uint16_t port = atoi(argv[1]);std::unique_ptr<HttpServer> tsvr(new HttpServer(HandlerHttp, port));tsvr->InitServer();tsvr->Start();return 0;
}
端口号必须是自己的云服务器接受的端口,这个在官网上看自己的主机,比如UCloud就是这样:
2、有效载荷长度
到现在为止,读到空行部分就知道报头读完了,接下来读有效载荷,但这样并不知道有效载荷有多长。有效载荷的长度在报头的中一个Key:Content-Length,它的Value就是Body的长度,也就是有效载荷的长度。响应这里做好有效载荷后,再给到客户端,客户端就需要知道有效载荷的长度,如果不知道就没法在字节流中提取。不过浏览器对此有更专业的解决办法,即使没有报头也能知道有效载荷的长度。
我们可以自己写上报头
std::string HandlerHttp(const std::string& request)
{//这里就已经默认request是一个完整的http请求报文//返回的是一个http reponse std::cout << "-------------------------------------" << std::endl;std::cout << request << std::endl;//按照响应格式来写std::string body = "<html> <body> <h1>this is a test</h1></body></html>";//响应行std::string response = "HTTP/1.0 200 ok" + SEP;//固定写法,这里假设200是OK的意思//报头response += "Content-Length: " + std::to_string(body.size()) + SEP;//报头的格式,是状态行,就得加上\r\n//空行response += SEP;response += body;//当作有效载荷, 也就是正文部分return response;
}
这样的话,有的浏览器会把< html > < body >打印出来,有的则会直接打印this is a test,这就是浏览器的处理不同。
3、客户端要访问的资源类型
除了长度,有效载荷本身就是一个混合的,会包含各种资源,那么客户端就得告诉服务端需要返回的是什么资源,服务端再去做处理。这个也是报头的一个Key:Content-Type,表示Body的种类。
不论是图片还是音频,本质都是文件,且都有自己的后缀,有Content-Type表,我们见到的后缀放到Content-Type中的写法。
std::string HandlerHttp(const std::string& request)
{//这里就已经默认request是一个完整的http请求报文//返回的是一个http reponse std::cout << "-------------------------------------" << std::endl;std::cout << request << std::endl;//按照响应格式来写std::string body = "<html> <body> <h1>this is a test</h1></body></html>";//响应行std::string response = "HTTP/1.0 200 ok" + SEP;//固定写法,这里假设200是OK的意思//报头response += "Content-Length: " + std::to_string(body.size()) + SEP;//报头的格式,是状态行,就得加上\r\nresponse += "Cpntent-Type: text/html" + SEP;//空行response += SEP;response += body;//当作有效载荷, 也就是正文部分return response;
}
响应一下
此时浏览器中上方框中输入自己云服务器的公网IP:端口号,就可以出来this is a test了,当然上面的httpserver也得在自己的云服务器上才行。
4、修改响应写法
如果写了要访问的路径是/,也就是不是具体的路径,响应的一方该如何处理?我们的body不能这样写,直接写出一个html文件的内容,对于http,需要一个专门的能够访问客户端要求的资源的地方,这里就得维护一个目录,在之前建立的Http_v1目录内再建一个目录,将要访问的资源都放在这个目录内,资源目录内再建一个index.html文件,把之前test.html的内容放到index.html中。我们再写一个头文件,里面有一个工具类,为了读完整个文件。
先看main.cc
#include "HttpServer.hpp"
#include "err.hpp"
#include "Util.hpp"
#include <memory>const std::string SEP = "\r\n";
const std::string path = "./wwwroot/index.html";std::string HandlerHttp(const std::string& request)
{//这里就已经默认request是一个完整的http请求报文//返回的是一个http reponse std::cout << "-------------------------------------" << std::endl;std::cout << request << std::endl;//资源,图片(.png, .jpg......),网页(.html, .htm),视频(.mp3)std::string body;Util::ReadFile(path, &body);//读出来后给到body字符串中
然后再写Util.hpp
#pragma once
#include <iostream>
#include <string>class Util
{
public://一般网页文件都是文本的,但图片、视频、音频则是二进制的static bool ReadFile(const std::string& path, std::string* fileContent){//1、获取文件本身的大小//2、调整string的空间//3、以二进制形式读取}
};
获取文件那里用一个函数stat,根据文件路径这个文件的stat结构体的一些属性。
成功返回0,失败返回-1。
#pragma once
#include <iostream>
#include <string>
#include <cstdlib>
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>
#include <fcntl.h>
#include "log.hpp"class Util
{
public://一般网页文件都是文本的,但图片、视频、音频则是二进制的static bool ReadFile(const std::string& path, std::string* fileContent){//1、获取文件本身的大小struct stat st;int n = stat(path.c_str(), &st);if(n < 0) return false;int size = st.st_size;//2、调整string的空间fileContent->resize(size);//3、以二进制形式读取int fd = open(path.c_str(), O_RDONLY);//需要<fcntl.h>if(fd < 0) return false;//把内容读到字符串流指定的内存缓冲区的起始地址read(fd, (char*)fileContent->c_str(), size);//当作字符串用,需要加(char*)close(fd);logMessage(Info, "read file %s done", path.c_str());return true;}
};
5、处理不同的请求
现在我们的做法是收到请求不做处理,只是打印一句this is a test。请求行中的URL是web根目录,不一定是Linux根目录。对于读到的请求要分辨出什么样的请求,然后处理请求。
在main.cc中,先对request做序列化反序列化,在对请求做出响应前做反序列化并分辨请求。
多个文件都有更改
main.cc
#include "HttpServer.hpp"
#include "err.hpp"
#include "Util.hpp"
#include <memory>
#include <vector>const std::string SEP = "\r\n";
const std::string path = "./wwwroot/index.html";class HttpRequest
{
public:HttpRequest(){}void Print(){logMessage(Debug, "method: %s, url: %s, version: %s", method_.c_str(), url_.c_str(), httpVsersion_.c_str());for(const auto& line : body_){logMessage(Debug, "-%s", line.c_str());}}~HttpRequest(){}
public:std::string method_;std::string url_;std::string httpVsersion_;std::vector<std::string> body_;
};HttpRequest Deserialize(std::string& message)//里面的两个函数放在Util.hpp中
{//这里就默认这是一个完整的http请求报文HttpRequest req;std::string line = Util::ReadOneLine(message, SEP);Util::ParseRequestLine(line, &req.method_, &req.url_, &req.httpVsersion_);while(!message.empty()){line = Util::ReadOneLine(message, SEP);req.body_.push_back(line);}return req;
}std::string HandlerHttp(std::string& message)
{//1、读取请求//这里就已经默认request是一个完整的http请求报文//返回的是一个http reponse std::cout << "-------------------------------------" << std::endl; std::cout << message << std::endl;//资源,图片(.png, .jpg......),网页(.html, .htm),视频(.mp3)//2、反序列化和分析请求HttpRequest req = Deserialize(message);req.Print();//3、使用请求std::string body;//Util::ReadFile(path, &body);//读出来后给到body字符串中Util::ReadFile(req.url_, &body);//响应行std::string response = "HTTP/1.0 200 ok" + SEP;//固定写法,这里假设200是OK的意思//报头response += "Content-Length: " + std::to_string(body.size()) + SEP;//报头的格式,是状态行,就得加上\r\nresponse += "Cpntent-Type: text/html" + SEP;//空行response += SEP;response += body;//当作有效载荷, 也就是正文部分return response;
}
Util.hpp
#pragma once
#include <iostream>
#include <string>
#include <cstdlib>
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>
#include <fcntl.h>
#include <sstream>
#include "log.hpp"class Util
{
public://一般网页文件都是文本的,但图片、视频、音频则是二进制的static bool ReadFile(const std::string& path, std::string* fileContent){//1、获取文件本身的大小struct stat st;int n = stat(path.c_str(), &st);if(n < 0) return false;int size = st.st_size;//2、调整string的空间fileContent->resize(size);//3、以二进制形式读取int fd = open(path.c_str(), O_RDONLY);//需要<fcntl.h>if(fd < 0) return false;//把内容读到字符串流指定的内存缓冲区的起始地址read(fd, (char*)fileContent->c_str(), size);//当作字符串用,需要加(char*)close(fd);logMessage(Info, "read file %s done", path.c_str());return true;}static std::string ReadOneLine(std::string& message, const std::string& sep){auto pos = message.find(sep);if(pos == std::string::npos) return "";std::string s = message.substr(0, pos);message.erase(0, pos+sep.size());return s;}//形式是GET /a/b/c.ico HTTP/1.1static bool ParseRequestLine(const std::string& line, std::string* method, std::string* url, std::string* httpVersion){//stringstream对字符串有多种便利的用法std::stringstream ss(line);ss >> *method >> *url >> *httpVersion;return true;}
};
HttpServer.hpp文件中回调函数那里去掉const
using func_t = std::function<std::string(std::string&)>;
当main.cc中走到使用请求这一步时,req就是请求行的内容,它已经反序列化,都填充好各个成员了,但要访问req.url_,也就是访问路径,要从我们维护的wwwroot中访问,而不是Linux根目录。把之前的全局变量path换成这个。
//const std::string path = "./wwwroot/index.html";
const std::string webRoot = "wwwroot";//web根目录//...//3、使用请求std::string body;//Util::ReadFile(path, &body);//读出来后给到body字符串中//Util::ReadFile(req.url_, &body);//对于获取到的url,比如/a/b/c.html,不能让这个目录放到Linux根目录下,而是我们的wwwroot目录std::string path = webRoot;path += req.url_;//"wwwroot/a/b/c.html"
也可以往HttpRequest类中添加一个path_成员,构造函数里给它构造为webRoot,反序列化函数Deserialize里就写好path_,返回req,req中就有处理好的路径。
class HttpRequest
{
public:HttpRequest():path_(webRoot){}void Print(){logMessage(Debug, "method: %s, url: %s, version: %s", method_.c_str(), url_.c_str(), httpVsersion_.c_str());for(const auto& line : body_){logMessage(Debug, "-%s", line.c_str());}}~HttpRequest(){}
public:std::string method_;std::string url_;std::string httpVsersion_;std::vector<std::string> body_;std::string path_;
};HttpRequest Deserialize(std::string& message)//里面的两个函数放在Util.hpp中
{//这里就默认这是一个完整的http请求报文HttpRequest req;std::string line = Util::ReadOneLine(message, SEP);Util::ParseRequestLine(line, &req.method_, &req.url_, &req.httpVsersion_);while(!message.empty()){line = Util::ReadOneLine(message, SEP);req.body_.push_back(line);}req.path_ += req.url_;return req;
}
但是如果路径就是一个/,那么添加上后,就是./wwwroot/,那这就是把这个目录所有内容都显示出来,所以这样就得限制一下。
//一般一个webserver,不做特殊说明,如果用户之间默认访问'/',不能把整站给对方
//需要添加默认首页!!而且,不能让用户访问wwwroot里面的任何一个目录本身,也可以给每一个目录都带上一个默认首页
const std::string defaultHomePage = "index.html";//我们写的就先不考虑目录内的目录,wwwroot里只有文件//...void Print(){logMessage(Debug, "method: %s, url: %s, version: %s", method_.c_str(), url_.c_str(), httpVsersion_.c_str());for(const auto& line : body_){logMessage(Debug, "-%s", line.c_str());}logMessage(Debug, "path: %s", path_.c_str());}//...
HttpRequest Deserialize(std::string& message)//里面的两个函数放在Util.hpp中
{//这里就默认这是一个完整的http请求报文HttpRequest req;std::string line = Util::ReadOneLine(message, SEP);Util::ParseRequestLine(line, &req.method_, &req.url_, &req.httpVsersion_);while(!message.empty()){line = Util::ReadOneLine(message, SEP);req.body_.push_back(line);}//对于获取到的url,比如/a/b/c.html,不能让这个目录放到Linux根目录下,而是我们的wwwroot目录req.path_ += req.url_;if(req.path_[req.path_.size() - 1] == '/') req.path_ += defaultHomePage;return req;
}
Print函数里也添加了一个日志打印。使用请求那里就这样写
//3、使用请求std::string body;Util::ReadFile(req.path_, &body);//读出来后给到body字符串中
多写2个html文件
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>测试</title>
</head>
<body><h1>hello file1</h1><h1>hello file1</h1><h1>hello file1</h1><h1>hello file1</h1><h1>hello file1</h1><h1>hello file1</h1><h1>hello file1</h1></body>
</html>
file1改成file2,总共2个html文件。做好这些工作后,我们再继续实际的处理。如果要访问图片,我们也得能加载图片。随便搜个图片
在wwwroot目录内创建一个image目录,在image目录内用wget 地址来获取图片,但有一些图片不可以获取,那就换一张。失败最下面有Bad Request,成功则是:
可以用mv命令修改图片名字
搜图片时会发现,一张网页包含很多资源,比如图片文字,每一个资源都要发起一次请求。这里只显示一点文字和图片,都放到一个html文件中,http检测到有图片就会再发一次请求。
index.html
<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Document</title>
</head><!--><!--上面需要加上,可以显示中文,也能正常显示我们的内容--><body><h1>this is a test</h1><h1>this is a test</h1><h1>this is a test</h1><h1>this is a test</h1><image src="/image/1.jpeg" alt="这是一张柯基图"> <!--网页中嵌入图片,显示失败就会打印alt的内容-->
</body></html>
回到main.cc,有效载荷的长度还是一样,获取到就可以了,不过载荷的内容因为既有文字又有图片,得改一下代码。在HttpRequest类中再加上一个表示文件后缀的成员变量,也是string类型,通过这个后缀来辨别资源。
void Print(){logMessage(Debug, "method: %s, url: %s, version: %s", method_.c_str(), url_.c_str(), httpVsersion_.c_str());for(const auto& line : body_){logMessage(Debug, "-%s", line.c_str());}logMessage(Debug, "path: %s", path_.c_str());logMessage(Debug, "suffix_: %s", suffix_.c_str());}HttpRequest Deserialize(std::string& message)//里面的两个函数放在Util.hpp中
{//这里就默认这是一个完整的http请求报文HttpRequest req;std::string line = Util::ReadOneLine(message, SEP);Util::ParseRequestLine(line, &req.method_, &req.url_, &req.httpVsersion_);while(!message.empty()){line = Util::ReadOneLine(message, SEP);req.body_.push_back(line);}//对于获取到的url,比如/a/b/c.html,不能让这个目录放到Linux根目录下,而是我们的wwwroot目录req.path_ += req.url_;if(req.path_[req.path_.size() - 1] == '/') req.path_ += defaultHomePage;auto pos = req.path_.rfind(".");//rfind是从尾开始找if(pos == std::string::npos) req.suffix_ = ".html";else req.suffix_ = req.path_.substr(pos); return req;
}std::string GetContentType(const std::string& suffix)//搜索后缀与Content-Type来找对应的写法
{std::string constent_type = "Content-Type: ";if(suffix == ".html" || suffix == ".htm") constent_type + "text/html";else if(suffix == ".css") constent_type += "text/css";else if(suffix == ".js") constent_type += "application/x-javascript";else if(suffix == ".png") constent_type += "image/png";else if(suffix == ".jpg") constent_type += "image/jpeg";else if(suffix == ".jpeg") constent_type += "image/jpeg";else {}return constent_type + SEP;
}std::string HandlerHttp(std::string& message)
{//1、读取请求//这里就已经默认request是一个完整的http请求报文//返回的是一个http reponse std::cout << "-------------------------------------" << std::endl;std::cout << message << std::endl; //资源,图片(.png, .jpg......),网页(.html, .htm),视频(.mp3)//2、反序列化和分析请求HttpRequest req = Deserialize(message);req.Print();//3、使用请求std::string body;Util::ReadFile(req.path_, &body);//读出来后给到body字符串中//响应行std::string response = "HTTP/1.0 200 ok" + SEP;//固定写法,这里假设200是OK的意思//报头response += "Content-Length: " + std::to_string(body.size()) + SEP;//报头的格式,是状态行,就得加上\r\nresponse += GetContentType(req.suffix_);//空行response += SEP;response += body;//当作有效载荷, 也就是正文部分return response;
}
图片出来得慢是因为图片可能比较大,用更小的就可以了。
6、跳转
file1,file2,index三个互相跳转,用到< a href >
<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>测试</title>
</head><body><h1>hello file2</h1><h1>hello file2</h1><h1>hello file2</h1><h1>hello file2</h1><h1>hello file2</h1><h1>hello file2</h1><h1>hello file2</h1><a href="/file1.html">file1</a><a href="/">返回首页</a></body></html>
<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Document</title>
</head><!--上面需要加上,可以显示中文,也能正常显示我们的内容--><body><h1>test</h1><image src="/image/1.jpeg" alt="这是一张柯基图"><br /> <!--网页中嵌入图片,显示失败就会打印alt的内容--><a href="/file1.html">file1</a><a href="/file2.html">file2</a>
</body></html>
跳转本质上就是浏览器重新解释标签,再发起请求。
3、请求方法(GET/POST)
GET是最常用的,POST将个人信息提交到服务器。大多数情况都只用GET/POST,其它基本不怎么用。请求方法是浏览器客户端发起的,它会构建一个http request,携带者GET/POST。使用请求方法,整个界面也需要有交互界面,交互需要表单。
<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Document</title>
</head><!--上面需要加上,可以显示中文,也能正常显示我们的内容-->
<!--想让两行之间有空行,就可以一行后面加<br />,下一行单独一个<br />--><body><form action="/a/b/c.exe" , method="GET">姓名: <input type="text" name="myname" value="name"><br /><br />密码: <input type="text" name="mypasswd" value="password"><br /><br /><input type="submit" value="提交"><br /><br /></form><h1>test</h1><h1>test</h1><h1>test</h1><h1>test</h1><image src="/image/1.jpeg" alt="这是一张柯基图"><br /> <!--网页中嵌入图片,显示失败就会打印alt的内容--><a href="/file1.html">file1</a><a href="/file2.html">file2</a>
</body></html>
action后是要访问的资源,value是默认值,点击提交后就会下载c.exe应用程序,不过不能使用。点击提交后出现这个网址,问号后面的就是要提交给c.exe的参数。
http://106.75.12.79:3389/a/b/c.exe?myname=zyd&mypasswd=123456
GET能获取一个静态网页,也能提交参数,通过URL的方式提交。默认提交方式是GET,把method这项去掉就是默认。把密码那里的input type改成password,输入密码就变成黑点了。method可以改成POST,不过我们没有处理body,body可能没有提取完,所以就会挂掉。POST提交后的网址是:
http://106.75.12.79:3389/a/b/c.exe
POST请求提交数据的时候,是通过有效载荷,也就是正文部分提交参数的,在云服务器中,我们会看到报头后空行之下就有提交的参数。
GET不私密,因为账户密码都显示在URL上,POST比较私密一些。所有的登陆注册支付等行为,都使用POST。url一般有大小约束,正文部分理论上可以非常大。
GET/POST都不安全,POST的请求方式,软件抓取,同一局域网都可以抓取过来。
4、HTTP状态码(实现3和4开头的)
状态码表示响应请求的结果是否正确。
4开头的状态码是客户端的问题,客户端可以发出各种各样的请求,但并不是所有请求都得满足,所有请求服务端都必须要满足,有违规的,不合要求的请求服务端就通过状态码来通知客户端,它的请求不能实现。
我们也可以在上面的代码基础上加上一个404,找别人的cv一下:
err_404.html
<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8"><title>404 Not Found</title><style>body {text-align: center;padding: 150px;}h1 {font-size: 50px;}body {font-size: 20px;} a {color: #008080;text-decoration: none;}a:hover {color: #005F5F;text-decoration: underline;}</style>
</head><body><div><h1>404</h1><p>页面未找到<br></p><p>您请求的页面可能已经被删除、更名或者您输入的网址有误。<br>请尝试使用以下链接或者自行搜索:<br><br><a href="https://www.baidu.com">百度一下></a></p></div>
</body></html>
在main.cc中
const std::string page_404 = "./wwwroot/err_404.html";//全局std::string HandlerHttp(std::string &message)
{// 1、读取请求// 这里就已经默认request是一个完整的http请求报文// 返回的是一个http reponsestd::cout << "-------------------------------------" << std::endl;std::cout << message << std::endl;// 资源,图片(.png, .jpg......),网页(.html, .htm),视频(.mp3)// 2、反序列化和分析请求HttpRequest req = Deserialize(message);req.Print();// 3、使用请求std::string body;std::string response;if (true == Util::ReadFile(req.path_, &body)) // 读出来后给到body字符串中{// 响应行response = "HTTP/1.0 200 ok" + SEP; // 固定写法,这里假设200是OK的意思// 报头response += "Content-Length: " + std::to_string(body.size()) + SEP; // 报头的格式,是状态行,就得加上\r\nresponse += GetContentType(req.suffix_);// 空行response += SEP;response += body; // 当作有效载荷, 也就是正文部分return response;}else{response = "HTTP/1.0 404 Not Found" + SEP;Util::ReadFile(page_404, &body);response += "Content-Length: " + std::to_string(body.size()) + SEP;response += GetContentType(".html");response += SEP;response += body;}return response;
}
这样再请求时,可以写公网ip:端口号/路径,访问一个不存在的文件就会404。当然也可以在index.html里加上一个跳转到404的网页
<a href="/err_404.html">404文件</a>
5开头的服务器错误,服务器处理请求时错误,这个就是写服务器的时候有问题,比如进程线程时出现问题,这些很少看见,即使有也不太会出现5开头的状态码,可能会出现1开头的状态码。这样显得有些随意,是因为浏览器对于各种协议的支持并不是很好,所以即使我直接显示500 OK也行。但还是按照标准走就可以。
3开头的是重定向状态码,网上可以搜到重定向状态码的意思,301永久重定向,302、307临时重定向,307和302差不多,不过307用get来重定向。这里的重定向是指,有些服务端已经换了地址,但请求方不知道,还是向旧的服务端请求,这时候旧服务端就会告知http改了地址,http就会再次请求到新服务端。
临时和永久的区别是,临时不更改浏览器的地址信息,客户端每次都去原本的地址访问,然后再重定向到临时的地址,而永久是更改了url,更改浏览器的本地书签信息,客户端一次重定向后就一直去访问新的地址。报头location和临时重定向状态码配合使用。接下来我们实现302状态码。
HandlerHttp函数中传入message后,我们就直接重定向到qq官网,就不做其它操作了。
std::string HandlerHttp(std::string &message)
{// 1、读取请求// 这里就已经默认request是一个完整的http请求报文// 返回的是一个http reponsestd::cout << "-------------------------------------" << std::endl;std::cout << message << std::endl;//4、重定向std::string response = "HTTP/1.0 302 Found" + SEP;response += "Location: https://im.qq.com/index/" + SEP;response += SEP;return response;
}
那么此时还是之前的步骤,运行起来后,就在浏览器输入公网ip:开放的端口号,就直接来到qq了。也可以换成301
std::string response = “HTTP/1.0 301 Moved Permanently” + SEP;
不过也没有永久重定向,改成301,用一个端口号后,Ctrl + C停止,make clean,把代码改成以前的,也还是以前的界面。
在登陆时,登录一次就会重定向到首页,每次过来都需要登录;打开某个网页也会打开别的网页,这都是临时重定向。
搜索引擎需要重定向,遇到某个资源已经换了网址,如果是永久重定向就返回新的地址,临时重定向还会每次都转一遍。
5、HTTP常见Header
6、http的会话保持功能(Cookie)
http本身是无状态的。http不会记住浏览过的网址,只会一遍遍请求。http是关于超文本传输,对于一个基于http的网站用户是否已经登录,它不去管理。但用户需要,http就有会话保持功能,但是http间接提供的。会话保持能够记录用户是否在线,并持续记录。http通过cookie和session来保持会话。
在登录时,服务器会通过http选项来向本地浏览器写入cookie信息。如果删除cookie信息就得重新登录了,登录一次又会加入cookie。
cookie原理在于,假设一个视频需要VIP,客户端请求过去就需要登录,客户端提交账户密码,服务端查找是否有这个用户,通过后就给定向到首页。
当首次认证通过后,服务器通过一些http选项,比如Set-Cookie,把用户的信息写入到http响应中,浏览器收到携带Set-Cookie的信息时,将response中相应的cookie信息在本地进行保存。保存有两种方法,内存级和文件级。之后访问同样的网站时,服务器发送给响应方的http request会包含cookie信息,不需要用户手动操作,这个是浏览器自动做的。
客户端访问每一个资源的时候,都需要认证。有了cookie,就可以不需要每次都输入。一次登录后续不需要再次登录的,基本都用了cookie技术。
恶意网站,有时候会一次性下载多个软件,软件中会有木马病毒,木马病毒就会拿用户的cookie信息,拿cookie信息去登录各个网站,比如被盗号了。如果是文件级的保存,即使关闭软件,网站也还会存在,内存级则不是。
上面所说的其实都是老方案,也不安全。在客户端和服务端之间交互时,客户端提交账户和密码,服务端不是直接存到cookie里,而是形成一个session对象,用当前用户的基本信息填充,这个session对象可以是内存或文件级的,每一个session都有唯一的id,是十或十六进制形成的序列,然后把http request Set-Cookie: session id发给客户端,客户端把session id保存到本地的cookie里,只保存session id和过期的时间,之后再登录时,http request都会携带cookie,里面有session id,服务端就去检验是否有这个id,存在就可以访问,也不需要再输入账户密码。当黑客盗取session id后,依然可以登录,但是账户密码则不会泄漏,账户密码都保存在服务端中,而服务端在国内基本是阿里腾讯华为的服务器,攻破难度不言而喻。
session id不是为了防止信息被泄漏的,即使到现在也不能解决这个问题。全球用户非常多,防范水平参差不齐,所以没办法统一做到保护信息。只能厂商自己多加防护。
服务端需要识别到底是不是用户自己登录的,识别出来还得有解决办法,比如通过检验用户位置信息,发现异地登录,可能就会强制下线,并要求输入账户和密码,因为这两个黑客拿不到,只有用户知道,或者让session id失效。当然还有很多方法,比如发送给绑定的邮箱信息,各种提醒出现在用户手机上等等。
以上就是cookie + session的解决方案。
//上面的注释掉,只用cookie,一请求就已经假如cookie,有了seesion id,内容就是1234abcd//5、cookie && session试验std::string response;response += "HTTP/1.0 200 OK" + SEP;response += "Set-Cookie: sessionid=1234abcd" + SEP;response += SEP;return response;
session可以看作一个类,下面偏伪代码
class Session
{
public:Session(std::string name, std::string passwd):name_(name), passwd_(passwd){}~Session(){}
private:std::string name_;std::string passwd_;uint64_t loginTime_;int fd;int status;int sessionid;
};std::unordered_map<int, Session*> sessions;bool Login(std::string& message)
{std::string name;std::string passwd;if(check(name, passwd)){Session* session = new Session(name, passwd);int random = rand();sessions.insert(std::pair<int, Session*>(random, session));}http responseSet-Cookie: sessionid=random;
}std::string HandlerHttp(std::string &message)
{//...request->sessionid;sessions[sessionid]->status;
}
实际上会用redis来保护所用Session。
4、结束
如果传很多图片,http就要请求多次,效率很低。http 1.0有个Connection关键字的value是keep-alive,也就是常链接,一个链接塞入多个请求。
在请求时,会打印出GET /favicon.ico HTTP/1.1,中间的favicon.ico就是在访问一个网站时,上面框中前面的小图标,比如CSDN就是一个正方形,红底白C。有这个在就会请求图标,这个可以下载一个小图标,添加上去。
http对于报头的保护并不好,用户通信时会有数据安全问题,https来解决这个问题。下一篇写https协议。
本篇gitee
结束。