服务器开发中 Web 服务是一个基本的代码单元,将服务端的请求和响应部分的逻辑抽象出来形成框架,能够做到最高级别的框架级代码复用。本次项目将综合使用 C++11 及 Boost 中的 Asio 实现 HTTP 和 HTTPS 的服务器框架。
在 g++ 4.9 之前,regex 库并不支持 ECMAScript 的正则语法,换句话说,在 g++4.9 之前,g++ 对 C++11 标准库的支持并不完善,为保证本次项目的顺利进行,请确保将 g++ 版本升级至 4.9 以上。
// 下面的这段代码可以测试你的编译器对正则表达式的支持情况
#include
#include
int main()
{
std::regex r1("S");
printf("S works.\n");
std::regex r2(".");
printf(". works.\n");
std::regex r3(".+");
printf(".+ works.\n");
std::regex r4("[0-9]");
printf("[0-9] works.\n");
return 0;
}
如果你的运行结果遇到了下图所示的错误,说明你确实需要升级你的 g++ 了:
使用 g++ -v
可以查看到当前编译器版本:
如果你最后一行中的
gcc version
显示的是4.8.x
,那么你需要手动将编译器版本升级至4.9
以上,方法如下:
# 安装 add-apt-repository 工具 sudo apt-get install software-properties-common # 增加源 sudo add-apt-repository ppa:ubuntu-toolchain-r/test # 更新源 sudo apt-get update # 更新安装 sudo apt-get upgrade # 安装 gcc/g++ 4.9 sudo apt-get install gcc-4.9 g++-4.9 # 更新链接 sudo updatedb sudo ldconfig sudo update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-4.8 48 \ --slave /usr/bin/g++ g++ /usr/bin/g++-4.8 \ --slave /usr/bin/gcc-ar gcc-ar /usr/bin/gcc-ar-4.8 \ --slave /usr/bin/gcc-nm gcc-nm /usr/bin/gcc-nm-4.8 \ --slave /usr/bin/gcc-ranlib gcc-ranlib /usr/bin/gcc-ranlib-4.8 sudo update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-4.9 49 \ --slave /usr/bin/g++ g++ /usr/bin/g++-4.9 \ --slave /usr/bin/gcc-ar gcc-ar /usr/bin/gcc-ar-4.9 \ --slave /usr/bin/gcc-nm gcc-nm /usr/bin/gcc-nm-4.9 \ --slave /usr/bin/gcc-ranlib gcc-ranlib /usr/bin/gcc-ranlib-4.9
我们知道,HTTP 和 HTTPS 都是应用层的一种协议,他们的底层实际上是通过 TCP 进行传输的。因此,要实现一个 Web 框架,就必须要对浏览器访问 Web 服务器的过程做一个了解。
首先,服务端已经运行起了服务,因此在服务器启动后便开始通过 Socket 监听端口上的请求。这时,客户端浏览器想要访问服务器资源时,就会发送相应的 HTTP 或者 HTTPS 请求。当服务端收到请求后,就会处理这部分请求,返回客户端所需的资源。
仔细思考后不难发现,HTTP 和 HTTPS 两种方式的服务器之间在处理请求、返回请求唯一区别就在于他们究竟如何处理与客户端建立连接的方式上,也就是常说的 accept()
方法。
因此,我们在设计基类的时候,就可以将 accept()
方法设计成一个虚函数,留给具体的子类来实现这个方法,而真正对于服务器内部的相关逻辑,全部由基类 ServerBase
来实现。
对此,我们先确定好基类的基本设计,在ServerBase
模板类中,我们只需预留一个接口start()
用于启动服务器给外部调用。
由于子类需要实现虚函数 accept()
的具体方式,因此需要保留为 protected,由于我们需要对请求进行解析和应答,而这部分逻辑其实是与协议类型无关的,因此我们可以将这部分逻辑直接放到 ServerBase
中进行实现,到目前为止,我们有:
#ifndef SERVER_BASE_HPP
#define SERVER_BASE_HPP
#include
#include
namespace ShiyanlouWeb {
// socket_type 为 HTTP or HTTPS
template <typename socket_type>
class ServerBase {
public:
// 启动服务器
void start();
protected:
// 需要不同类型的服务器实现这个方法
virtual void accept() {}
// 处理请求和应答
void process_request_and_respond(std::shared_ptr socket) const;
};
template<typename socket_type>
class Server : public ServerBase {};
}
#endif /* SERVER_BASE_HPP */
ServerBase
实现一个 Web 服务器,最重要的就是对来自客户端的请求信息进行解析,为此,我们需要在 ShiyanlouWeb
命名空间中定义一个 Request
结构体:
struct Request {
// 请求方法, POST, GET; 请求路径; HTTP 版本
std::string method, path, http_version;
// 对 content 使用智能指针进行引用计数
std::shared_ptr<std::istream> content;
// 哈希容器, key-value 字典
std::unordered_map<std::string, std::string> header;
// 用正则表达式处理路径匹配
std::smatch path_match;
};
这个结构体用于解析请求,如请求方法,请求路径,HTTP 版本等信息。同时,并定义一个 std::istream 指针来保存请求体中包含的内容,由于我们并不关心 header 中信息的顺序,所以可以考虑使用 std::unordered_map
来指定一个无序容器,保存 header。此外,由于还需要对请求路径进行解析,我们不妨用正则表达式来处理这部分的解析。
在定义好请求后,我们还需要考虑服务器资源的类型。这个资源类型,决定了我们如何让别人使用我们的库,首先我们定义资源类型:
typedef std::map<std::string, std::unordered_map<std::string,
std::function<void(std::ostream&, Request&)>>> resource_type;
并在ServerBase
中定义好资源成员:
template <typename socket_type>
class ServerBase {
public:
// 用于服务器访问资源处理方式
resource_type resource;
// 用于保存默认资源的处理方式
resource_type default_resource;
protected:
// 用于内部实现对所有资源的处理
std::vector all_resources;
……
首先,resource_type
是一个 std::map
,其键为一个字符串,值则为一个无序容器std::unordered_map
,这个无序容器的键依然是一个字符串,其值接受一个返回类型为空、参数为 ostream 和 Request 的函数。
因此,我们在使用这套框架的时候,当我们有了一个 Server
的对象,定义资源可以是如下的形式:
// 处理访问 /info 的 GET 请求, 返回请求的信息
server.resource["^/info/?$"]["GET"] = [](ostream& response, Request& request) {
// 处理请求及资源
// ...
};
其中,std::map
用于存储请求路径的正则表达式,而 std::unordered_map
用于存储请求方法,而最后通过一个匿名 Lambda 函数来保存处理方法。有了资源类型,我们仅仅只是定义了当他人使用这套框架时的接口。
为此,我们还需要考虑实现其他接口需要的成员及方法。
首先,Boost Asio 库要求每一个应用都具有一个 io_service
对象的调度器,而实现 TCP socket 连接,就需要一个 acceptor
对象,而初始化一个 acceptor
还需要有一个 endpoint
对象,因此,我们需要在 ServerBase
类中的 protected
作用域内定义:
boost::asio::io_service m_io_service;
boost::asio::ip::tcp::endpoint endpoint;
boost::asio::ip::tcp::acceptor acceptor;
单个线程的服务器注定是鸡肋的,所以我们不妨在内部实现一个线程池,所以我们可以继续定义:
size_t num_threads;
std::vector<std::thread> threads;
整个 ServerBase
应该在被构造时完成一些关键成员的初始化,对于 endpoint
我们可以将其通过tcp::v4()
及申明的端口号进行初始化,完成后,在将 io_service
对象和 endpoint
共同交给 acceptor
进行初始化,因此我们有构造函数:
ServerBase(unsigned short port, size_t num_threads=1) :
endpoint(boost::asio::ip::tcp::v4(), port),
acceptor(m_io_service, endpoint),
num_threads(num_threads) {}
至此,我们整个 ServerBase
中的设计就变成了:
//
// server_base.hpp
// web_server
// created by changkun at shiyanlou.com
//
#ifndef SERVER_BASE_HPP
#define SERVER_BASE_HPP
#include
#include
#include
#include
namespace ShiyanlouWeb {
struct Request {
// 请求方法, POST, GET; 请求路径; HTTP 版本
std::string method, path, http_version;
// 对 content 使用智能指针进行引用计数
std::shared_ptr<std::istream> content;
// 哈希容器, key-value 字典
std::unordered_map<std::string, std::string> header;
// 用正则表达式处理路径匹配
std::smatch path_match;
};
// 使用 typedef 简化资源类型的表示方式
typedef std::map<std::string, std::unordered_map<std::string,
std::function<void(std::ostream&, Request&)>>> resource_type;
// socket_type 为 HTTP or HTTPS
template <typename socket_type>
class ServerBase {
public:
resource_type resource;
resource_type default_resource;
// 构造服务器, 初始化端口, 默认使用一个线程
ServerBase(unsigned short port, size_t num_threads=1) :
endpoint(boost::asio::ip::tcp::v4(), port),
acceptor(m_io_service, endpoint),
num_threads(num_threads) {}
void start();
protected:
// asio 库中的 io_service 是调度器,所有的异步 IO 事件都要通过它来分发处理
// 换句话说, 需要 IO 的对象的构造函数,都需要传入一个 io_service 对象
boost::asio::io_service m_io_service;
// IP 地址、端口号、协议版本构成一个 endpoint,并通过这个 endpoint 在服务端生成
// tcp::acceptor 对象,并在指定端口上等待连接
boost::asio::ip::tcp::endpoint endpoint;
// 所以,一个 acceptor 对象的构造都需要 io_service 和 endpoint 两个参数
boost::asio::ip::tcp::acceptor acceptor;
// 服务器线程
size_t num_threads;
std::vector<std::thread> threads;
// 所有的资源及默认资源都会在 vector 尾部添加, 并在 start() 中创建
std::vector all_resources;
// 需要不同类型的服务器实现这个方法
virtual void accept() {}
// 处理请求和应答
void process_request_and_respond(std::shared_ptr socket) const;
};
template<typename socket_type>
class Server : public ServerBase {};
}
#endif /* SERVER_BASE_HPP */
ServerBase
真正要实现的只有两个方法:
void ServerBase::start()
void ServerBase::process_request_and_respond()
首先来实现 start()
。实现 start()
时,我们要将考虑下面几个问题:
all_resources
中,当我们处理请求路径时,应该先处理好所有的非特殊路径,当找不到匹配请求路径时,再使用默认的请求资源。void start() {
// 默认资源放在 vector 的末尾, 用作默认应答
// 默认的请求会在找不到匹配请求路径时,进行访问,故在最后添加
for(auto it=resource.begin(); it!=resource.end();it++) {
all_resources.push_back(it);
}
for(auto it=default_resource.begin(); it!=default_resource.end();it++) {
all_resources.push_back(it);
}
// 调用 socket 的连接方式,还需要子类来实现 accept() 逻辑
accept();
// 如果 num_threads>1, 那么 m_io_service.run()
// 将运行 (num_threads-1) 线程成为线程池
for(size_t c=1;cthis](){
m_io_service.run();
});
}
// 主线程
m_io_service.run();
// 等待其他线程,如果有的话, 就等待这些线程的结束
for(auto& t: threads)
t.join();
}
再来实现 process_request_and_respond()
:
// 处理请求和应答
void process_request_and_respond(std::shared_ptr socket) const {
// 为 async_read_untile() 创建新的读缓存
// shared_ptr 用于传递临时对象给匿名函数
// 会被推导为 std::shared_ptr
auto read_buffer = std::make_shared();
boost::asio::async_read_until(*socket, *read_buffer, "\r\n\r\n",
[this, socket, read_buffer](const boost::system::error_code& ec, size_t bytes_transferred) {
if(!ec) {
// 注意:read_buffer->size() 的大小并一定和 bytes_transferred 相等, Boost 的文档中指出:
// 在 async_read_until 操作成功后, streambuf 在界定符之外可能包含一些额外的的数据
// 所以较好的做法是直接从流中提取并解析当前 read_buffer 左边的报头, 再拼接 async_read 后面的内容
size_t total = read_buffer->size();
// 转换到 istream
std::istream stream(read_buffer.get());
// 被推导为 std::shared_ptr 类型
auto request = std::make_shared();
// 接下来要将 stream 中的请求信息进行解析,然后保存到 request 对象中
……
});
}
当我们通过 read_buffer
拿到 istream
对象后,就需要对这个这些信息进行解析,然后保存到 request 中,为此,我们不妨增加一个 parse_request()
方法:
// 解析请求
Request parse_request(std::istream& stream) const {
Request request;
// 使用正则表达式对请求报头进行解析,通过下面的正则表达式
// 可以解析出请求方法(GET/POST)、请求路径以及 HTTP 版本
std::regex e("^([^ ]*) ([^ ]*) HTTP/([^ ]*)$");
std::smatch sub_match;
//从第一行中解析请求方法、路径和 HTTP 版本
std::string line;
getline(stream, line);
line.pop_back();
if(std::regex_match(line, sub_match, e)) {
request.method = sub_match[1];
request.path = sub_match[2];
request.http_version = sub_match[3];
// 解析头部的其他信息
bool matched;
e="^([^:]*): ?(.*)$";
do {
getline(stream, line);
line.pop_back();
matched=std::regex_match(line, sub_match, e);
if(matched) {
request.header[sub_match[1]] = sub_match[2];
}
} while(matched==true);
}
return request;
}
然后我们来继续实现 process_request_and_respond()
方法:
// 处理请求和应答
void process_request_and_respond(std::shared_ptr socket) const {
auto read_buffer = std::make_shared();
boost::asio::async_read_until(*socket, *read_buffer, "\r\n\r\n",
[this, socket, read_buffer](const boost::system::error_code& ec, size_t bytes_transferred) {
if(!ec) {
……
// 被推导为 std::shared_ptr 类型
auto request = std::make_shared();
*request = parse_request(stream);
size_t num_additional_bytes = total-bytes_transferred;
// 如果满足,同样读取
if(request->header.count("Content-Length")>0) {
boost::asio::async_read(*socket, *read_buffer,
boost::asio::transfer_exactly(stoull(request->header["Content-Length"]) - num_additional_bytes),
[this, socket, read_buffer, request](const boost::system::error_code& ec, size_t bytes_transferred) {
if(!ec) {
// 将指针作为 istream 对象存储到 read_buffer 中
request->content = std::shared_ptr<std::istream>(new std::istream(read_buffer.get()));
respond(socket, request);
}
});
} else {
respond(socket, request);
}
}
});
}
最后,在代码的最后,我们需要将请求的内容和 socket 一同传递给 respond()
来处理应答,因此,还需要增加一个 respond()
方法:
// 应答
void respond(std::shared_ptr socket, std::shared_ptr request) const {
// 对请求路径和方法进行匹配查找,并生成响应
for(auto res_it: all_resources) {
std::regex e(res_it->first);
std::smatch sm_res;
if(std::regex_match(request->path, sm_res, e)) {
if(res_it->second.count(request->method)>0) {
request->path_match = move(sm_res);
// 会被推导为 std::shared_ptr
auto write_buffer = std::make_shared();
std::ostream response(write_buffer.get());
res_it->second[request->method](response, *request);
// 在 lambda 中捕获 write_buffer 使其不会再 async_write 完成前被销毁
boost::asio::async_write(*socket, *write_buffer,
[this, socket, request, write_buffer](const boost::system::error_code& ec, size_t bytes_transferred) {
// HTTP 持久连接(HTTP 1.1), 递归调用
if(!ec && stof(request->http_version)>1.05)
process_request_and_respond(socket);
});
return;
}
}
}
}
当我们实现完报头解析、请求应答这两个重要的逻辑之后,剩下的,就是对针对不同类型的服务器实现不同的 accept()
方法了。
在 Boost 中,HTTP 类型就是普通的 socket 类型(boost::asio::ip::tcp::socket
)。为此,我们可以通过以下不到四十行代码简单实现 HTTP 服务器:
//
// server_http.hpp
// web_server
// created by changkun at shiyanlou.com
//
#ifndef SERVER_HTTP_HPP
#define SERVER_HTTP_HPP
#include "server_base.hpp"
namespace ShiyanlouWeb {
typedef boost::asio::ip::tcp::socket HTTP;
template<>
class Server : public ServerBase {
public:
// 通过端口号、线程数来构造 Web 服务器, HTTP 服务器比较简单,不需要做相关配置文件的初始化
Server(unsigned short port, size_t num_threads=1) :
ServerBase::ServerBase(port, num_threads) {};
private:
// 实现 accept() 方法
void accept() {
// 为当前连接创建一个新的 socket
// Shared_ptr 用于传递临时对象给匿名函数
// socket 会被推导为 std::shared_ptr 类型
auto socket = std::make_shared(m_io_service);
acceptor.async_accept(*socket, [this, socket](const boost::system::error_code& ec) {
// 立即启动并接受一个连接
accept();
// 如果出现错误
if(!ec) process_request_and_respond(socket);
});
}
};
}
#endif /* SERVER_HTTP_HPP */
现在我们可以来正式使用我们的框架了。到目前为止,我们一共创建了下面的这些文件:
src
├── server_base.hpp
└── server_http.hpp
我们的 HTTP Web 框架就只有这两个核心文件。
下面我们可以基于我们的 Web 框架开发一个 Web 服务器了:
首先,我们创建好 main
逻辑:
//
// main_http.cpp
// web_server
// created by changkun at shiyanlou.com
//
#include "server_http.hpp"
#include "handler.hpp"
using namespace ShiyanlouWeb;
int main() {
// HTTP 服务运行在 12345 端口,并启用四个线程
Server server(12345, 4);
start_server>(server);
return 0;
}
在这个逻辑中,有 start_server
这个方法,传递了一个 Server
对象。 而 handler.hpp
则负责实现我们整个 HTTP Web 服务器实例的逻辑。
在开发这个处理逻辑的时候,我们之前提到的框架资源类型定义了我们向外提供的接口,使用形式如下所示:
// 处理访问 /info 的 GET 请求, 返回请求的信息
server.resource["^/info/?$"]["GET"] = [](ostream& response, Request& request) {
// 处理请求及资源
// ...
};
为了测试 GET 请求和 POST 请求,我们先创建一个 www
目录来存放我们的 Web 资源,随便编写一些 HTML 代码:
<html>
<head>
<title>Shiyanlou Web Server Testtitle>
head>
<body>
Hello world in index.html.
body>
html>
我们可以编写下面的服务器测试代码:
//
// handler.hpp
// web_server
// created by changkun at shiyanlou.com
//
#include "server_base.hpp"
#include
using namespace std;
using namespace ShiyanlouWeb;
template<typename SERVER_TYPE>
void start_server(SERVER_TYPE &server) {
// 向服务器增加请求资源的处理方法
// 处理访问 /string 的 POST 请求,返回 POST 的字符串
server.resource["^/string/?$"]["POST"] = [](ostream& response, Request& request) {
// 从 istream 中获取字符串 (*request.content)
stringstream ss;
*request.content >> ss.rdbuf(); // 将请求内容读取到 stringstream
string content=ss.str();
// 直接返回请求结果
response << "HTTP/1.1 200 OK\r\nContent-Length: " << content.length() << "\r\n\r\n" << content;
};
// 处理访问 /info 的 GET 请求, 返回请求的信息
server.resource["^/info/?$"]["GET"] = [](ostream& response, Request& request) {
stringstream content_stream;
content_stream << "Request:
";
content_stream << request.method << " " << request.path << " HTTP/" << request.http_version << "
";
for(auto& header: request.header) {
content_stream << header.first << ": " << header.second << "
";
}
// 获得 content_stream 的长度(使用 content.tellp() 获得)
content_stream.seekp(0, ios::end);
response << "HTTP/1.1 200 OK\r\nContent-Length: " << content_stream.tellp() << "\r\n\r\n" << content_stream.rdbuf();
};
// 处理访问 /match/[字母+数字组成的字符串] 的 GET 请求, 例如执行请求 GET /match/abc123, 将返回 abc123
server.resource["^/match/([0-9a-zA-Z]+)/?$"]["GET"] = [](ostream& response, Request& request) {
string number=request.path_match[1];
response << "HTTP/1.1 200 OK\r\nContent-Length: " << number.length() << "\r\n\r\n" << number;
};
// 处理默认 GET 请求, 如果没有其他匹配成功,则这个匿名函数会被调用
// 将应答 web/ 目录及其子目录中的文件
// 默认文件: index.html
server.default_resource["^/?(.*)$"]["GET"] = [](ostream& response, Request& request) {
string filename = "web/";
string path = request.path_match[1];
// 防止使用 `..` 来访问 web/ 目录外的内容
size_t last_pos = path.rfind(".");
size_t current_pos = 0;
size_t pos;
while((pos=path.find('.', current_pos)) != string::npos && pos != last_pos) {
current_pos = pos;
path.erase(pos, 1);
last_pos--;
}
filename += path;
ifstream ifs;
// 简单的平台无关的文件或目录检查
if(filename.find('.') == string::npos) {
if(filename[filename.length()-1]!='/')
filename+='/';
filename += "index.html";
}
ifs.open(filename, ifstream::in);
if(ifs) {
ifs.seekg(0, ios::end);
size_t length=ifs.tellg();
ifs.seekg(0, ios::beg);
// 文件内容拷贝到 response-stream 中,不应该用于大型文件
response << "HTTP/1.1 200 OK\r\nContent-Length: " << length << "\r\n\r\n" << ifs.rdbuf();
ifs.close();
} else {
// 文件不存在时,返回无法打开文件
string content="Could not open file "+filename;
response << "HTTP/1.1 400 Bad Request\r\nContent-Length: " << content.length() << "\r\n\r\n" << content;
}
};
// 运行 HTTP 服务器
server.start();
}
到目前为止,我们整个目录树应该是这个样子:
├── handler.hpp
├── main_http.cpp
├── server_base.hpp
├── server_http.hpp
└── web
└── index.html
由于我们使用了 boost 库,因此需要在编译时告诉编译器去索引 boost 的位置,如果直接使用编译命令编译,会出现指令过长的情况,我们不妨编写一个 Makefile:
#
# Makefile
# web_server
#
# created by changkun at shiyanlou.com
#
CXX = g++
EXEC_HTTP = server_http
SOURCE_HTTP = main_http.cpp
OBJECTS_HTTP = main_http.o
# 开启编译器 O3 优化, pthread 启用多线程支持
LDFLAGS_COMMON = -std=c++11 -O3 -pthread -lboost_system
LDFLAGS_HTTP =
LPATH_COMMON = -I/usr/include/boost
LPATH_HTTP =
LLIB_COMMON = -L/usr/lib
LLIB_HTTP =
http:
$(CXX) $(SOURCE_HTTP) $(LDFLAGS_COMMON) $(LDFLAGS_HTTP) $(LPATH_COMMON) $(LPATH_HTTP) $(LLIB_COMMON) $(LLIB_HTTP) -o $(EXEC_HTTP)
clean:
rm -f $(EXEC_HTTP) *.o
最终,我们能够使用 make http
来编译我们的代码,并通过 ./server_http
来运行我们的服务器,并在浏览器中测试我们的服务器运行情况:
对于 GET 请求,我们可以直接在浏览器中访问:
localhost:12345/ # 会访问到 index.html
localhost:12345/match/123abc # 会获得到一个 123abc 的字符串
localhost:12345/info/ # 会获得整个请求体的信息
而对于 POST 请求,我们可以使用 curl
命令进行测试:
curl -d "test string" "http://localhost:12345/string/"
这时候能看到服务器返回测试结果,就是我们 POST 发送的字符串。
本节实验我们实现了服务器除开建立 TCP 连接具体实现外的 ServerBase
基类,并从此类继承出了 Server
子类,实现了 HTTP 服务器框架,同时,基于我们编写的框架,我们开发出了一个简易的 HTTP Web 服务器。在整个过程中,我们用到了大量 C++11 和 Boost Asio 的相关知识。在下一节实验中,我们将据此进一步实现 HTTPS 服务器框架,并编写启用 HTTPS 服务器。
原文地址:https://www.shiyanlou.com/courses/568/labs/1984/document