目录
模块设计思想
本模块就是设计一个HttpServer模块,提供便携的搭建http协议的服务器的方法。
那么这个模块需要如何设计呢? 这还需要从Http请求说起。
首先http请求是分为静态资源请求和功能性请求的。
静态资源请求顾名思义就是用来获取服务器中的某些路径下的实体资源,比如文件的内容等,这一类请求中,url 中的资源路径必须是服务器中的一个有效的存在的文件路径。
而如果提取出来的资源路径并不是一个实体文件的路径,那么他大概率是一个功能性请求,这时候就有用户来决定如何处理这个请求了,也就是我们前面说过的 请求路径 和 处理方法的路由表。
但是还有一种特殊的情况就是资源路径是一个目录,比如 / ,这时候有可能是一个访问网站首页的请求,所以我们需要判断在这个路径后面加上 index.html (也可以是其他的文件名,取决于你的网站的首页的文件名) ,如果加上之后,路径有效且存在实体文件,那么就是一个静态资源请求,如果还是无效,那么就是一个功能性请求。
而功能性请求如何处理呢?这是由使用或者说搭建服务器的人来决定的。 用户未来想要提供某些功能,可以让他和某个虚拟的目录或者说特定的路径绑定起来。 比如提供一个登录功能,那么用户可以规定 /login 这个路径就代表登录的功能,未来如果收到了一个请求资源路径是 /login ,那么就不是请求实体资源,而是调用网站搭建者提供的登录的方法进行验证等操作。 一般来说这些虚拟路径不会和实体资源路径冲突。
同时,对于这种功能性请求对应的路径,他并不是说一个路径只能有一个功能,不同的请求方法,同一个路径,最终执行的方法也可以是不同的,这具体还是要看使用者的设定。
所以为了维护这样的功能性路径和需要执行的方法之间的映射关系,我们需要为每一种请求方法都维护一张路由表,路由表中其实就是保存了路径和所需要执行的方法之间的映射关系。
在我们这里,就只考虑常用的五种方法,get,post,delete,head,put,其他的暂时就不提供支持了。
//五张路由表
using Handler = std::function<void(const HttpRequest&,HttpResponse*)>;
using HandlerTable = std::unordered_map<std::string,Handler>;
HandlerTable _get_route;
HandlerTable _post_route;
HandlerTable _head_route;
HandlerTable _put_route;
HandlerTable _delete_route;
这是交给用户进行设置的,我们也会提供五个接口给用户用来添加处理方法。
但是,这样的表真的好吗?
在实际的应用中,比如有以下的功能性请求的请求路径 , /login1213 , /login12124 , /login1213626 , /login12152 , /login1295 , /login1275 ,对于这样的一类路径,他们其实需要执行的是同一个方法,而并不需要为每一个类似的路径设置一个方法,而路径后半部分的数字其实后续可以当成参数来用。
那么综上所述,我们的路由表中作为 key 值的并不是 std::string ,而是只需要满足某一种匹配要求的路径,都可以执行某一方法,那么作为 key 值的其实是正则表达式。
using HandlerTable = std::unordered_map<std::regex,Handler>;
但是如果我们编译一下就会发现,正则表达式是不能作为哈希的 key 值的,或者说不匹配默认的哈希函数。
我们可以思考一下,我们用正则表达式作为 key 了,那么后面不管使用何种数据结构来存储正则表达式和操作方法的映射关系,我们都是要遍历整个路由表的,需要遍历表中的所有的正则表达式,然后拿着我们的路径来进行正则匹配,匹配上了就说明这是我们要找的方法,如果匹配不上就说明不是,不管怎么样,都是要进行遍历,那么其实我们直接用数组来存储也是一样的。
所以最终我们使用 vector 来存储用户方法。
using HandlerTable = std::vector<std::pair<std::regex,Handler>>;
而HttpServer模块中除了五张路由表,还需要一个TcpServer对象,这是毋庸置疑的。 同时还需要保存一个网页根目录,这个根目录是要交给用户设置的,由使用者决定。
那么最终HttpServer的成员如下:
//支持Http协议的服务器
class HttpServer
{
private:
TcpServer _server;
std::string _base_path; //网页根目录
//五张路由表
using Handler = std::function<void(const HttpRequest&,HttpResponse*)>;
using HandlerTable = std::vector<std::pair<std::regex,Handler>>;
HandlerTable _get_route;
HandlerTable _post_route;
HandlerTable _head_route;
HandlerTable _put_route;
HandlerTable _delete_route;
public:
};
后续我们都不需要写构造函数。
那么需要哪些接口呢?
然后就是提供给用户的五个设置功能方法的接口,以及设置网页根目录和服务器线程数的接口。
还需要提供给用户是否开启超时释放,以及启动服务器的接口。
提供给用户的接口就这么多,其实都很简单,难的是私有的一些接口:
首先,未来拿到一个完整请求之后,我们需要能够判断这个请求是静态资源请求还是功能性请求。如果是资源性请求我们需要怎么做? 如果是功能性请求我们有需要怎么做?
最后还需要将相应组织成一个tcp报文进行回复。
同时还需要提供未来设置给TcpServer的连接建立和新数据到来的回调方法,这两个方法是必需的,其他的三个倒是无所谓。因为在连接建立时我们必须要设置上下文,在新数据到来时必须要有逻辑来决定怎么处理。
至于具体的实现,我们一步一步慢慢来。
模块代码实现
首先实现几个简单的提供给用户的接口:当然这里的Start或者说构造还没有完全实现,因为我们还没有设置连接建立回调和新数据回调这两个回调方法。
public:
void SetBasePath(const std::string& basedir)
{
_base_path = basedir;
}
void Get(const std::regex& e , const Handler& cb) //设置GET
{
_get_route.push_back(std::make_pair(e,cb));
}
void Post(const std::regex& e , const Handler& cb) //设置POST
{
_post_route.push_back(std::make_pair(e,cb));
}
void Put(const std::regex& e , const Handler& cb) //设置PUT
{
_put_route.push_back(std::make_pair(e,cb));
}
void Head(const std::regex& e , const Handler& cb) //设置HEAD
{
_head_route.push_back(std::make_pair(e,cb));
}
void Delete(const std::regex& e , const Handler& cb) //设置DELETE
{
_delete_route.push_back(std::make_pair(e,cb));
}
void EnableInactiveRelease(int delay = 30) //启动非活跃销毁
{
_server.EnableInactiveRelease(delay);
}
void SetThreadCount(int cnt) //设置线程数量
{
_server.SetThreadCount(cnt);
}
void Start() //启动服务器
{
_server.Start();
}
那么剩下的就是连接建立回调以及新数据回调的逻辑了,
首先连接建立的时候,我们需要设置一个上下文给Connection对象。
void OnConnect(const PtrConnection& conn)
{
//设置一个上下文
HttpContext ctx;
conn->SetContext(ctx);
}
剩下的就是最复杂的新数据回调了。
void OnMessage(const PtrConnection& conn,Buffer* buf) //获取新数据回调
{
}
首先第一步需要将上下文获取出来。
// 1 获取上下文
Any* context = conn->GetContext();
HttpContext* pctx = context->GetData<HttpContext>();
然后就需要通过上下文对缓冲区数据进行解析,也就是调用HttpContext的接口进行处理,但是我们要看处理结果是什么来判断下一步怎么做。
// 2 解析缓冲区数据
pctx->RecvHttpRequest(buf);
HttpRequest& req = pctx->GetRequest();
HttpResponse resp;
//判断解析是否出错
if(pctx->RespStatu() >= 400) //请求解析出错,此时的_recv_statu 也一定是RECV_ERR
{
HandlerError(req,resp); //调用错误处理方法
WriteResponse(conn,req,resp); //返回响应
conn->ShutDown(); //发生错误就关闭连接
return;
}
if(pctx->RecvStatu() != RECV_OVER) //还没收到一个完整请求
return;
//走到这里说明req是一个完整的请求
void HandlerError(HttpRequest& req , HttpResponse& resp);
void WriteResponse(const PtrConnection& conn , HttpRequest& req , HttpResponse& resp);
这里用到的两个接口我们一会再来实现。
接受到一个请求之后,其实我们就需要进行方法的路由了,那么我们直接再封装成一个接口。
// 3 数据处理,路由
Route(req,resp); //进行方法路由,判断是不是静态资源请求。
void Route(HttpRequest& req , HttpResponse& resp);
那么路由的过程中会填充好我们的响应的关键信息。
处理完之后,我们就需要将响应发回给客户端。
// 4 返回给客户端
WriteResponse(conn,req,resp);
最后我们需要判断需不需要关闭连接,因为Http协议是请求应答式的服务,一般来说,只处理一个请求之后就会关闭连接。但是我们不要忘了长连接这个技术,也就是说,如果对方支持长连接,那么我们就不需要关闭连接,而是重置上下文之后进行下一个请求的处理。
// 5 处理完之后重置上下文
pctx->Reset();
// 6 判断长短连接
if(resp.Close()) //如果是短连接就直接关闭
{
conn->ShutDown();
return;
}
//如果是长连接就需要搞成循环,读取下一个报文
如果是长连接的话,那么我们上面的处理的流程就应该是循环式的。
void OnMessage(const PtrConnection& conn,Buffer* buf) //获取新数据回调
{
while(buf->ReadSize() > 0) //从逻辑上来说 while(1) 也是一样的
{
// 1 获取上下文
Any* context = conn->GetContext();
HttpContext* pctx = context->GetData<HttpContext>();
// 2 解析缓冲区数据
pctx->RecvHttpRequest(buf);
HttpRequest& req = pctx->GetRequest();
HttpResponse resp;
//判断解析是否出错
if(pctx->RespStatu() >= 400) //请求解析出错,此时的_recv_statu 也一定是RECV_ERR
{
HandlerError(req,resp); //调用错误处理方法
WriteResponse(conn,req,resp); //返回响应
conn->ShutDown(); //发生错误就关闭连接
return;
}
if(pctx->RecvStatu() != RECV_OVER) //还没收到一个完整请求
return;
//走到这里说明req是一个完整的请求
// 3 数据处理,路由
Route(req,resp); //进行方法路由,判断是不是静态资源请求。
// 4 返回给客户端
WriteResponse(conn,req,resp);
// 5 处理完之后重置上下文
pctx->Reset();
// 6 判断长短连接
if(resp.Close()) //如果是短连接就直接关闭
{
conn->ShutDown();
return;
}
//如果是长连接就需要搞成循环,读取下一个报文
}
}
那么接下来就是里面用到的接口的实现了。
我们先来完成Route接口,在路由的接口中,首先我们需要判断资源路径是不是静态资源,如果是,那么就需要读取文件,如果不是,那么就需要进行任务的路由或者说派发。
void Route(HttpRequest& req , HttpResponse& resp)
{
if(IsFileResquest(req,resp)) //判断是否是静态资源请求
return FileHandler(req,resp);
//否则就需要到几个方法表中进行路由
if(req._method == "GET")
return Dispatcher(req,resp,_get_route);
if(req._method == "POST")
return Dispatcher(req,resp,_post_route);
if(req._method == "PUT")
return Dispatcher(req,resp,_put_route);
if(req._method == "HEAD")
return Dispatcher(req,resp,_head_route);
if(req._method == "DELETE")
return Dispatcher(req,resp,_delete_route);
//如果走到了这里,说明前面的处理方法都不行,那么一定是请求出问题了
resp._statu = 405; //Method Not Allowed
HandlerError(req,resp,resp->_statu);
}
那么静态资源如何判断处理呢?下面是判断的方法:
bool IsFileResquest(HttpRequest& req , HttpResponse& resp) //判断以及处理静态资源
{
// 1 首先需要判断有没有设置资源根目录
if(_base_path.empty()) return false; //肯定不是静态资源请求
// 2 静态资源请求的方法必须是 GET 或者 HEAD ,因为其他的方法不是用来获取资源的
if(!(req._method == "GET" || req._method == "HEAD")) return false;
//然后静态资源请求的路径必须是一个合法的路径
if(Util::IsValid(req._path) == false) return false;
//最后就需要判断请求的路径的资源是否存在
//但是我们需要考虑路径是目录的时候,给它加上一个 index.html
std::string path = req._path;
if(path.back() == '/') path += "index.html";
//判断文件是否存在
DEBUG_LOG("path:%s",path.c_str());
std::string real_path = _base_path+path;
if(Util::IsRegular(real_path) == false) return false;
return true; //走到这里才算是一个静态资源请求
}
静态资源方法如何处理? 其实很简单,将文件读取出来放到响应的正文就行了,不过读取完之后还需要设置一些响应的Content相关的头部字段。
void HandlerFile(HttpRequest& req , HttpResponse& resp) //处理静态资源请求
{
std::string path = _base_path+req._path;
if(path.back() == '/') path +="index.html";
Util::ReadFile(path,&resp._body);
//然后设置响应头部字段
//在这里我们可以只设置 Content-Type 字段,Content-Length可以交给WriteResponse接口来设置
std::string mime = Util::GetMime(path);
resp.AddHeader("Content-Type",mime);
}
然后就是功能性请求的路由,其实就是遍历方法表进行匹配就行了。
void Dispatcher(HttpRequest& req , HttpResponse& resp , const HandlerTable& table)
{
for(std::pair<const std::regex& , Handler> p: table)
{
const std::regex& e = p.first;
const Handler& cb = p.second;
std::smatch matches;
bool ret = std::regex_match(req._path,matches,e);
if(ret) return cb(req,&resp);
}
//走到这里说明路由表中没有对应的方法
resp._statu = 404; //Not Found
HandlerError(req,resp,resp->_statu);
}
那么到此为止,路由的方法就解决了。
剩下的就是错误处理以及响应的格式化了。
错误的处理我们可以返回一个错误的展示界面
void HandlerError(HttpRequest& req , HttpResponse& resp ,int statu)
{
std::string body;
body += "<!DOCTYPE html>";
body += "<html><head><title>";
body += std::to_string(statu);
body += Util::StatuDesc(statu);
body += "</title></head><body><h1>抱歉,该页面无法找到。</h1>";
body += "<p>请检查您输入的网址是否正确,或者 <a href=\"/\">返回首页</a>。</p>";
body += "</body></html>";
resp._body = body;
resp.AddHeader("Content-Type","text/html");
}
最后就是处理一下我们的WriteResponse接口,
第一步需要完善响应的报头字段:
if(req.Close()) resp.AddHeader("Connection","close");
else resp.AddHeader("Connection","keep-alive");
if(resp._body.size()&&!resp.HasHeader("Content-Length")) resp.AddHeader("Content-Length",std::to_string(resp._body.size()));
if(resp._body.size() && !resp.HasHeader("Content-Type")) resp.AddHeader("Content-Type","application/octet-stream");
//重定向信息
if(resp._redirect_flag) resp.AddHeader("Location",resp._redirect_url);
然后需要按指定格式组织响应,我们可以使用 osstream 这个字符流对象
// 2 组织响应
std::ostringstream out;
//响应行 HTTP/1.0 404 NotFound\r\n
out<<req._version<<" "<<std::to_string(resp._statu)<<" "<<Util::StatuDesc(resp._statu)<<"\r\n";
//头部字段
for(auto& p : resp._headers)
{
out<<p.first<<": "<<p.second<<"\r\n";
}
//空行
out<<"\r\n";
//正文
out<<resp._body;
最后就是发送出去
// 3 发送
conn->Send(out.str().c_str(),out.str().size());
那么WriteResponse的总体的代码:
void WriteResponse(const PtrConnection& conn , HttpRequest& req , HttpResponse& resp)
{
// 1 先把响应的头部字段完善了
if(req.Close()) resp.AddHeader("Connection","close");
else resp.AddHeader("Connection","keep-alive");
if(resp._body.size()&&!resp.HasHeader("Content-Length")) resp.AddHeader("Content-Length",std::to_string(resp._body.size()));
if(resp._body.size() && !resp.HasHeader("Content-Type")) resp.AddHeader("Content-Type","application/octet-stream");
//重定向信息
if(resp._redirect_flag) resp.AddHeader("Location",resp._redirect_url);
// 2 组织响应
std::ostringstream out;
//响应行 HTTP/1.0 404 NotFound\r\n
out<<req._version<<" "<<std::to_string(resp._statu)<<" "<<Util::StatuDesc(resp._statu)<<"\r\n";
//头部字段
for(auto& p : resp._headers)
{
out<<p.first<<": "<<p.second<<"\r\n";
}
//空行
out<<"\r\n";
//正文
out<<resp._body;
// 3 发送
conn->Send(out.str().c_str(),out.str().size());
}
那么最后我们再完善一下构造函数,需要传入一个端口号来对我们内部的TcpServer对象进行初始化,以及绑定两个回调函数,
HttpServer(int port ,int delay = 30):_server(port)
{
_server.EnableInactiveRelease(delay); //我们的http服务器默认是开启超时释放的
_server.SetConnectCallBack(std::bind(&HttpServer::OnConnect,this,std::placeholders::_1));
_server.SetMessageCallBack(std::bind(&HttpServer::OnMessage,this,std::placeholders::_1,std::placeholders::_2));
}
那么我们也会注意到一个问题,就是新数据到来进行处理的时候,解析失败会调用 HandlerError和WriteResponse,而WriteResponse中会用到 req 的version ,但是我们实际上可能并没有读取到,所以我们可以给version一个初始值,可以给HttpRequest增加一个构造函数。
HttpRequest():_version("HTTP/1.0"){}
其他的倒是没什么大问题了。
那么我们的http服务器的设计也就设计完了。
为了防止头文件重复包含,我们也需要加上条件编译。
#ifndef __HTTP__MUDUO__SERVER
#define __HTTP__MUDUO__SERVER
// 头文件内容
#endif
我们的服务器的代码编译是没有问题的,后续我们会对其进行测试,来修正项目中的一些没有注意到的bug。