C++ 开发 Web 服务框架

基础知识:C++11 与 Boost Asio

一、概述

项目介绍

服务器开发中 Web 服务是一个基本的代码单元,将服务端的请求和响应部分的逻辑抽象出来形成框架,能够做到最高级别的框架级代码复用。本次项目将综合使用 C++11 及 Boost 中的 Asio 实现 HTTP 和 HTTPS 的服务器框架。

项目涉及的知识点

  • C++基本知识
    • 面向对象
    • 模板
    • 命名空间
    • 常用 IO 库
  • C++11 相关
    • lambda expression
    • std::shared_ptr
    • std::make_shared
    • std::unordered_map
    • std::regex
    • std::smatch
    • std::regex_match
    • std::function
    • std::thread
  • Boost Asio 相关
    • boost::asio::io_service
    • boost::asio::ip::tcp::socket
    • boost::asio::ip::tcp::v4()
    • boost::asio::ip::tcp::endpoint
    • boost::asio::ip::tcp::acceptor
    • boost::asio::streambuf
    • boost::asio::async_read
    • boost::asio::async_read_until
    • boost::asio::async_write
    • boost::asio::transfer_exactly
    • boost::asio::ssl::stream
    • boost::asio::ssl::stream_base::server
    • boost::asio::ssl::context
    • boost::asio::ssl::context::sslv23
    • boost::asio::ssl::context::pem
    • boost::system::error_code

二、编译环境介绍

在 g++ 4.9 之前,regex 库并不支持 ECMAScript 的正则语法,换句话说,在 g++4.9 之前,g++ 对 C++11 标准库的支持并不完善,为保证本次项目的顺利进行,请确保将 g++ 版本升级至 4.9 以上。

// 下面的这段代码可以测试你的编译器对正则表达式的支持情况
#include <iostream>
#include <regex>

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++ 了:

1-2-1

使用 g++ -v 可以查看到当前编译器版本:

1-2-2

如果你最后一行中的 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

此外,本次项目依赖了 Boost 和 OpenSSL 这两个库,不过好在实验楼的环境已经提供了这两个非常基本的库,你不需要再操心他们的安装了。

三、C++ 基础

面向对象和模板是 C++进阶知识的基础,这里不做过多介绍,本次项目我们将开发一个 Web 框架,我们在这里先回顾一下命名空间、和 sstream 字符串 IO 流的相关知识。如果对这部分比较熟悉,可以直接跳过本小节。

3.1 命名空间

在开发库时,库通常会有定义大量的全局名称,这时候当我们使用的库越来越多时,就不可避免的发生名称冲突的情况,这也就是我们常说的命名空间污染。

在命名空间诞生以前,通常使用的办法就是把一个函数、类、甚至变量名等名字取得足够长,在每一个名字的前面都增加相应的前缀,例如,当我们只想要定义一个 port 的变量时候:

// 原本的样子
int port;
// 实际的样子
int shiyanlou_web_server_port;

命名空间的定义非常简单,通过关键字 namespace 加上命名空间的名字,再使用花括号包裹需要的定义和声明即可完成相关的定义,例如:

namespace shiyanlou_web_server {
    int port = 0;
}

这时,这个 port 就被限制在了命名空间 shiyanlou_web_server 当中,如果不通过命名空间的指定,就不会被访问到。

参考下面的例子:

//
// main.cpp
//
#include <iostream>
#include "web.hpp"
#include "web2.hpp"
int main() {
    std::cout << "hello world!" << std::endl;
    std::cout << "shiyanlou_web_server, port=" << shiyanlou_web_server::port << std::endl;
    std::cout << "shiyanlou_web2_server, port=" << shiyanlou_web2_server::port << std::endl;
    return 0;
}
// 
// web.hpp
//
namespace shiyanlou_web_server{
    int port = 0;
}
//
// web2.hpp
//
namespace shiyanlou_web2_server{
    int port = 2;
}

最后的输出结果为:

hello world!
shiyanlou_web_server, port=0
shiyanlou_web2_server, port=2

3.2 常用 IO 库

我们常说的 C++ IO 库一般指 iostreamfstreamsstream

  • iostream 包含了 istream(从流读)/ostream(从流写)/iostream(读写流)
  • fstream 包含了 ifstream(从文件读)/ofstream(condition 文件写)/fstream(读写文件)
  • sstream 包含了 istringstream(从 string 读)/ostringstream(向 string 写)/stringstream(读写 string)

其实标准库还有宽字符版本,但我们这里不讨论,有兴趣的话可以参考参考链接。

iostream 和 fstream 是两个比较常用的IO 库,我们这里不再回顾,这里简单回顾一下 sstream

如果你熟悉 C 语言,就知道将 int 转换为 string 类型其实是一件很麻烦的事情,虽然标准库中提供了 itoa() 这种函数,但是依然需要对转换后的 C 风格字符串(char *)通过 std::string 的构造函数构造为 std::string。 如果使用流操作,那么这将变得异常的简单:

#include <string>
#include <sstream>
#include <iostream>

int main() {
    // std::stringstream 支持读写
    std::stringstream stream;
    std::string result;
    int number = 12345;
    stream << number;   // 将 number 输入到 stream
    stream >> results;  // 从 stream 读取到 result
    std::cout < result << std::endl; // 将输出为字符串"12345"
}

如果希望让sstream 和 C 风格的字符串打交道,同样也可以:

#include <sstream>
#include <iostream>

int main()
{
    std::stringstream stream;
    char result[6];
    stream << 12345;
    stream >> result;
    std::cout << result << std::endl;
}

需要注意的一点就是,在进行多次IO 操作时,如果希望结果彼此不影响,需要对 stream 对象进行一次 clear() 操作:

stream.clear()

四、C++11 相关

C++11 几乎重新定义了 C++ 的一切,C++11 的出现伴随着大量的有用的新特性和标准库,这些特性和标准使得 C++ 变得更加现代,甚至在编码范式上都与传统 C++ 有着本质上的差异,本节我们将回顾一下这些特性:

  • lambda expression
  • std::shared_ptr
  • std::make_shared
  • std::unordered_map
  • std::regex
  • std::smatch
  • std::regex_match
  • std::function
  • std::thread

如果对这些特性比较熟悉,可以直接跳过本节。

4.1 lambda 表达式

Lambda 表达式是 C++11中最重要的新特性之一,而 Lambda 表达式,实际上就是提供了一个类似匿名函数的特性,而匿名函数则是在需要一个函数,但是又不想费力去命名一个函数的情况下去使用的。这样的场景其实有很多很多,所以匿名函数几乎是现代编程语言的标配。

Lambda 表达式的基本语法如下:

[捕获列表](参数列表) mutable(可选) 异常属性 -> 返回类型 {
    // 函数体
}

上面的语法规则除了 [捕获列表] 内的东西外,其他部分都很好理解,只是一般函数的函数名被略去,返回值使用了一个 -> 的形式进行。

所谓捕获列表,其实可以理解为参数的一种类型,lambda 表达式内部函数体在默认情况下是不能够使用函数体外部的变量的,这时候捕获列表可以起到传递外部数据的作用。根据传递的行为,捕获列表也分为以下几种:

1. 值捕获

与参数传值类似,值捕获的前期是变量可以拷贝,不同之处则在于,被捕获的变量在 lambda 表达式被创建时拷贝,而非调用时才拷贝:

void learn_lambda_func_1() {
    int value_1 = 1;
    auto copy_value_1 = [value_1] {
        return value_1;
    };
    value_1 = 100;
    auto stored_value_1 = copy_value_1();
    // 这时, stored_value_1 == 1, 而 value_1 == 100.
    // 因为 copy_value_1 在创建时就保存了一份 value_1 的拷贝
}

2. 引用捕获

与引用传参类似,引用捕获保存的是引用,值会发生变化。

void learn_lambda_func_2() {
    int value_2 = 1;
    auto copy_value_2 = [&value_2] {
        return value_2;
    };
    value_2 = 100;
    auto stored_value_2 = copy_value_2();
    // 这时, stored_value_2 == 100, value_2 == 100.
    // 因为 copy_value_2 保存的是引用
}

3. 隐式捕获

手动书写捕获列表有时候是非常复杂的,这种机械性的工作可以交给编译器来处理,这时候可以在捕获列表中写一个 & 或 = 向编译器声明采用 引用捕获或者值捕获.

总结一下,捕获提供了lambda 表达式对外部值进行使用的功能,捕获列表的最常用的四种形式可以是:

  • [] 空捕获列表
  • [name1, name2, ...] 捕获一系列变量
  • [&] 引用捕获, 让编译器自行推导捕获列表
  • [=] 值捕获, 让编译器执行推导应用列表

std::shared_ptr, std::make_shared

C++11 在内存管理上同样做了很多改进,std::make_shared 就是其中之一。它是和 std::shared_ptr 共同出现的,std::shared_ptr 是一种智能指针,它能够记录多少个 shared_ptr 共同指向一个对象(熟悉 Objective-C 的可能知道,这种特性叫做引用计数),能够消除显式的调用 delete,当引用计数变为0的时候就会将对象自动删除。

但还不够,因为使用 std::shared_ptr 仍然需要使用 new来调用,这使得代码出现了某种程度上的不对称。因此就需要另一种手段(工厂模式)来解决这个问题。

std::make_shared 就能够用来消除显式的使用 new,所以std::make_shared 会分配创建传入参数中的对象,并返回这个对象类型的std::shared_ptr指针。例如:

#include <iostream>
#include <memory>

void foo(std::shared_ptr<int> i)
{
    (*i)++;
}
int main()
{
    // 构造了一个 std::shared_ptr
    auto pointer = std::make_shared<int>(10);
    foo(pointer);
    std::cout << *pointer << std::endl;
}

4.2 无序容器 std::unordered_map

在传统的 C++中,我们已经熟知了 std::map 关联容器,std::map 容器在插入元素时,会根据 < 操作符比较元素大小并判断元素是否相同,并选择合适的位置插入到容器中。当对这个容器中的元素进行遍历时,输出结果会按照 < 操作符的顺序来逐个遍历。

而 C++11 终于推出了无序容器。 std::unordered_map 就是无序容器其中之一,这个容器会计算元素的 Hash 值,并根据 Hash 值来判断元素是否相同。由于无序容器没有定义元素之间的顺序,仅靠 Hash 值来判断元素是否已经存在于容器中,所以遍历 std::unordered_map 时,结果是无序的。

来看一个例子:

#include <iostream>
#include <string>
#include <unordered_map>
#include <map>

int main() {
    // 两组结构按同样的顺序初始化
    std::unordered_map<int, std::string> u = {
        {1, "1"},
        {3, "3"},
        {2, "2"}
    };
    std::map<int, std::string> v = {
        {1, "1"},
        {3, "3"},
        {2, "2"}
    };

    // 分别对两组结构进行遍历
    std::cout << "std::unordered_map" << std::endl;
    for( const auto & n : u) 
        std::cout << "Key:[" << n.first << "] Value:[" << n.second << "]\n";

    std::cout << std::endl;
    std::cout << "std::map" << std::endl;
    for( const auto & n : v) 
        std::cout << "Key:[" << n.first << "] Value:[" << n.second << "]\n";
}

最终的输出结果为:

std::unordered_map
Key:[2] Value:[2]
Key:[3] Value:[3]
Key:[1] Value:[1]

std::map
Key:[1] Value:[1]
Key:[2] Value:[2]
Key:[3] Value:[3]

可以看到 std::map 的遍历结果是有序的,而 std::unordered_map 的遍历结果是无序的。

事实上,std::unordered_map 在单个元素访问时,总是能够获得更高的性能。

4.3 std::regex/std::regex_match/std::smatch

正则表达式是一个独立于 C++ 语言本身的另一个很大的话题,我们这里不详细讨论它的行为。

作为学习 std::regex 的一些介绍性内容,我们这里说明一下接下来会用到的一些正则表达式:

  • [a-z]+\.txt: 在这个正则表达式中, [a-z] 表示匹配一个小写字母, + 可以使前面的表达式匹配多次,因此 [a-z]+ 能够匹配一个小写字母组成的字符串。在正则表达式中一个 . 表示匹配任意字符,而 \. 则表示匹配字符 .,最后的 txt 表示严格匹配 txt 则三个字母。因此这个正则表达式的所要匹配的内容就是由纯小写字母组成的文本文件。

std::regex_match 用于匹配字符串和正则表达式,有很多不同的重载形式。最简单的一个形式就是传入std::string 以及一个 std::regex 进行匹配,当匹配成功时,会返回 true,否则返回 false。例如:

#include <iostream>
#include <string>
#include <regex>

int main() {
    std::string fnames[] = {"foo.txt", "bar.txt", "test", "a0.txt", "AAA.txt"};
    // 在 C++ 中 `\` 会被作为字符串内的转义符,为使 `\.` 作为正则表达式传递进去生效,需要对 `\` 进行二次转义,从而有 `\\.`
    std::regex txt_regex("[a-z]+\\.txt");
    for (const auto &fname: fnames)
        std::cout << fname << ": " << std::regex_match(fname, txt_regex) << std::endl;
}

另一种常用的形式就是依次传入 std::string/std::smatch/std::regex 三个参数,其中 std::smatch 的本质其实是 std::match_results,在标准库中, std::smatch 被定义为了 std::match_results<std::string::const_iterator>,也就是一个子串迭代器类型的 match_results。使用 std::smatch 可以方便的对匹配的结果进行获取,例如:

std::regex base_regex("([a-z]+)\\.txt");
std::smatch base_match;
for(const auto &fname: fnames) {
    if (std::regex_match(fname, base_match, base_regex)) {
        // sub_match 的第一个元素匹配整个字符串
        // sub_match 的第二个元素匹配了第一个括号表达式
        if (base_match.size() == 2) {
            std::string base = base_match[1].str();
            std::cout << "sub-match[0]: " << base_match[0].str() << std::endl;
            std::cout << fname << " sub-match[1]: " << base << std::endl;
        }
    }
}

以上两个代码段的输出结果为:

foo.txt: 1
bar.txt: 1
test: 0
a0.txt: 0
AAA.txt: 0
sub-match[0]: foo.txt
foo.txt sub-match[1]: foo
sub-match[0]: bar.txt
bar.txt sub-match[1]: bar

4.4 std::function

std::function 是一种通用、多态的函数封装,它的实例可以对任何可以调用的目标实体进行存储、复制和调用操作,它也是对 C++中现有的可调用实体的一种类型安全的包裹(相对来说,函数指针的调用不是类型安全的),简而言之,std::function 就是函数的容器。

在前面的 Lambda 表达式中,我们已经介绍过使用 auto 关键字来接受一个 lambda 表达式。但有时候我们可能希望明确的指明这个 lambda 表达式的类型,这时就可以使用 std::function 来进行书写,例如:

#include <functional>
#include <iostream>

int foo(int para) {
    return para;
}

int main() {
    // std::function 包装了一个返回值为 int, 参数为 int 的函数
    std::function<int(int)> func = foo;
    std::cout << func(10) << std::endl;
}

4.5 std::thread

std::thread 用于创建一个执行的线程实例,所以它是一切并发编程的基础,使用时需要包含头文件,它提供了很多基本的线程操作,例如get_id()来获取所创建线程的线程 ID,例如使用 join() 来等待线程等等,例如:

#include <iostream>
#include <thread>
void foo() {
    std::cout << "hello world" << std::endl;
}
int main() {
    std::thread t(foo);
    t.join();
    return 0;
}

五、Boost Asio 相关

Boost 是一个 C++的可移植库,是对标准库的后备扩展,也是 C++标准化进程的开发引擎之一。Boost 库是由 C++标准委员会的成员发起的,里面发展的内容很有可能会成为 C++标准库的内容之一。因此 Boost 也是 C++社区中影响力最大的 『准』标准库。

Boost Asio,就是 Boost 库中的一个部分,Asio 的全称为 Asynchronous input and output (异步输入输出)的缩写。顾名思义,结合 Boost 的特点,Asio 提供了一套平台无关的异步数据处理能力(当然它也支持同步数据处理)。

本节我们将熟悉一下下面这些知识点的用法,如果你对这些内容比较熟悉,可以直接跳过本节。

  • boost::asio::io_service
  • boost::asio::ip::tcp::socket
  • boost::asio::ip::tcp::v4()
  • boost::asio::ip::tcp::endpoint
  • boost::asio::ip::tcp::acceptor
  • boost::asio::streambuf
  • boost::asio::async_read
  • boost::asio::async_read_until
  • boost::asio::async_write
  • boost::asio::transfer_exactly
  • boost::asio::ssl::stream
  • boost::asio::ssl::stream_base::server
  • boost::asio::ssl::context
  • boost::asio::ssl::context::sslv23
  • boost::asio::ssl::context::pem
  • boost::system::error_code

使用 Asio 只需要引入一个头文件即可:

#include <boost/asio.hpp>

对于所有使用 Asio 的程序,都必须要包含至少一个 io_service 对象。对于 Asio 这个 Boost 库而言,它抽象了诸如网络、串口通信等等这些概念,并将其统一规为 IO 操作,所以 io_service 这个类提供了访问 I/O 的功能。因此,使用 Asio 时,必须定义:

boost::asio::io_service io;

5.1 HTTP 连接

既然网络的相关概念已经被抽象为 IO,我们就只需要关心从这个 IO 流中获取消息,因此,我们本质上还是在进行 IO 操作,只不过这个操作需要具备一些基本的网络概念。

我们知道,HTTP 和 HTTPS 的底层实际上是使用的 TCP 可靠连接,通过 Socket 技术进行通信,而一个 Socket 由 IP 地址及端口构成。无例外地,Asio 同样也需要建立一个和 socket 有关的对象,那就是 boost::asio::ip::tcp::socket。可想而知,Socket 既然是网络通信的基础,那么自然的我们要进行的 IO 操作也就必须在这里完成,因此,我们定义的 boost::asio::ip::tcp::socket 对象,必须由 io_service 来进行构造,即:

boost::asio::ip::tcp::socket socket(io);

有了 socket 对象是不够的。在网络通信中,网络 IO 就入口串口一样,是以流的方式进行的。所以这个 socket 对象只能用来做我们日后进行 IO 操作时的一个必要属性。

不难看出,一个普通的 boost::asio::ip::tcp::socket 对象,实际上就是一个 HTTP 的 Socket 连接,因此我们在日后进行代码编写时,甚至于可以使用 typedef 将这个类型直接定义为 HTTP:

typedef boost::asio::ip::tcp::socket HTTP;

然而,作为服务端,我们可能构建很多很多的连接从而响应并发,所以当我们需要建立连接时候,就需要使用一个叫做 acceptor 的对象。

而 boost::asio::ip::tcp::acceptor 从名字上就可以看出,这个对象应该被用于建立连接。在 Boost 中,我们需要初始化一个 acceptor 对象时,必须提供一个 io_service 对象和一个 endpoint 对象。

那么 endpoint 又是什么?事实上,socket 是一个端到端的连接,所谓 endpoint 就是 socket 位于服务端的一个端点,我们知道,socket 是由 IP 地址和端口号组成的,那么当我们需要为其建立一个 IPv4 的网络,首先可以建立一个 boost::asio::ip::tcp::endpoint 对象:

unsigned short port = 8080;
boost::asio::ip::tcp::endpoint endpoint(boost::asio::ip::tcp::v4(), port);

其中 boost::asio::ip::tcp::v4() 用于初始化一个 IPv4 的网络。最后在使用这个 endpoint 对象来初始化 acceptor:

boost::asio::ip::tcp::acceptor acceptor(io, endpoint);

至此,我们讨论了如何使用 Asio 建立一个普通的网络操作对象 acceptor,以及在进行普通 HTTP 网络操作时需要的 socket 对象。

5.2 HTTPS 连接

讨论完了 Asio 里 HTTP 连接,我们再来看看 Asio 中的 HTTPS 是如何建立连接的。Asio 是一个开源的库,所以它也不可避免的在处理不擅长的逻辑时需要添加对别的框架的依赖。Asio 的 HTTPS 相关的 SSL 操作,就依赖了 OpenSSL 库。

要使用 SSL 相关的操作,还需要额外引入一个头文件:

#include <boost/asio/ssl.hpp>

我们在上一小节里讨论了 boost::asio::ip::tcp::socket 产生的 Socket 对象实际上就是普通的 HTTP 对象。对于 HTTPS 而言,实际上就是对这个 socket 所产生的通道进行一个一层封装和加密。在 Boost Asio 中,加密 socket 的方式就是使用 boost::asio::ssl::stream,并将 boost::asio::ip::tcp::socket 作为模板参数传入给这个对象,即:

typedef boost::asio::ssl::stream<boost::asio::ip::tcp::socket> HTTPS;

而当我们要构造一个 HTTPS 的 socket 对象时,Boost Asio 要求必须为这个 socket 建立一个 boost::asio::ssl::context 对象。而一个 context 可以有很多不同的类型,最常用的,就是boost::asio::ssl::context::sslv23。构造好了 context 对象之后这还不够,因为一个 https 的服务器需要提供证书文件和秘钥文件,所以还需要使用 use_certificate_chain_file() 和 use_private_key_file() 这两个方法来进行进一步的配置:

context.use_certificate_chain_file(cert_file);
context.use_private_key_file(private_key_file, boost::asio::ssl::context::pem);

其中的 boost::asio::ssl::context::pem 是指定的证书类型。因此相较于 HTTP 而言,HTTPS 的建立其实就是增加了对证书的配置、和 socket 加密的环节,对比如下:

// http
boost::asio::ip::tcp::socket http_socket(io);
// https
boost::asio::ssl::context context(boost::asio::ssl::context::sslv23);
context.use_certificate_chain_file(cert_file);
context.use_private_key_file(private_key_file, boost::asio::ssl::context::pem);
boost::asio::ssl::stream<boost::asio::ip::tcp::socket> https_socket(io, context);

5.3 IO 操作

上面我们讨论了如何建立连接,现在我们再来看看如何进行 IO 操作。

当我们有了 socket 对象之后,就可以从里面读取网络流数据了。读取数据时,我们需要定义一个流缓冲 boost::asio::streambuf 对象,用于逐行读取 socket 中的数据:

boost::asio::streambuf read_buffer;

另外,很多网络协议其实都是基于行实现的,也就是说这些协议元素是由 \r\n 符号进行界定,HTTP 也不例外,所以在 Boost Asio 中,读取使用分隔符的协议,可以使用 async_read_untile() 方法:

boost::asio::async_read_until(socket, readbuffer, "\r\n\r\n", read_handler);

其中 socket 就是我们的 socket 连接,而 readbuffer 就是根据界定符读取到的一行数据,"\r\n\r\n" 就是分隔符,而对于 read_handler 我们还需要再进一步讨论。

read_handler 是一个无返回类型的函数对象,它接受两个参数,一个是 boost::system::error_code,另一个是 size_tbytes_transferred):

void read_handler(
    const boost::system::error_code& ec,
    std::size_t bytes_transferred)
{
  ...
}

boost::system::error_code 用来描述操作是否成功,而 size_t bytes_transferred 则是用来确定接受到的字节数,通常情况下,我们可以用 std::bind 来将参数绑定到我们的某个函数传入,但实际上我们还有更好的做法,那就是 lambda 表达式,因为 Lambda 表达式还具有另外的一个功能,那就是进行值捕获,对于这一点,我们在后面实现框架的时候再详细讨论。

在这个 read_handler 中,我们实际上是在不断的读取 socket 里面的内容,因此我们还需要使用 boost::asio::async_read 对后面的内容进行进一步的读取,而它的用法 和 boost::asio::async_read_until 几乎一样,唯一的区别就是在 read_handler 这个参数之前,需要指定 读取的长度,通常我们可以使用 boost::asio::transfer_exactly 进行指定,故这里不再详细赘述,我们在后面实现框架的时候,再详细讨论。

最后,我们完成了读取的操作,就只剩下最后一步了,那就是服务器响应请求,回写请求的资源供给客户端,这时候我们就需要使用另一个方法:boost::asio::async_write。从名字上可以看出,这个方法和 boost::asio::async_read 属于同一个方法家族,可想而知用法也完全类似,我们还是留到后面的实际代码中再进行讨论。

六、总结

本节中我们回顾了 C++11 的相关知识,并对 Boost 中的 Asio 做了一些介绍。在下一节中,我们将综合运用上面提到的全部知识,开始实现我们的框架。

七、参考资料

  1. C++ 11/14/17 标准在各编译器下支持情况
  2. C++ IO 库
  3. C++ 正则表达式库
  4. Boost Asio 库

HTTP Web 框架的设计与实现

一、概述

项目介绍

服务器开发中 Web 服务是一个基本的代码单元,将服务端的请求和响应部分的逻辑抽象出来形成框架,能够做到最高级别的框架级代码复用。本次项目将综合使用 C++11 及 Boost 中的 Asio 实现 HTTP 和 HTTPS 的服务器框架。

项目涉及的知识点

  • C++基本知识
    • 面向对象
    • 模板
    • 命名空间
    • 常用 IO 库
  • C++11 相关
    • lambda expression
    • std::shared_ptr
    • std::make_shared
    • std::unordered_map
    • std::regex
    • std::smatch
    • std::regex_match
    • std::function
    • std::thread
  • Boost Asio 相关
    • boost::asio::io_service
    • boost::asio::ip::tcp::socket
    • boost::asio::ip::tcp::v4()
    • boost::asio::ip::tcp::endpoint
    • boost::asio::ip::tcp::acceptor
    • boost::asio::streambuf
    • boost::asio::async_read
    • boost::asio::async_read_until
    • boost::asio::async_write
    • boost::asio::transfer_exactly
    • boost::asio::ssl::stream
    • boost::asio::ssl::stream_base::server
    • boost::asio::ssl::context
    • boost::asio::ssl::context::sslv23
    • boost::asio::ssl::context::pem
    • boost::system::error_code

二、 编译环境提示

在 g++ 4.9 之前,regex 库并不支持 ECMAScript 的正则语法,换句话说,在 g++4.9 之前,g++ 对 C++11 标准库的支持并不完善,为保证本次项目的顺利进行,请确保将 g++ 版本升级至 4.9 以上。

// 下面的这段代码可以测试你的编译器对正则表达式的支持情况
#include <iostream>
#include <regex>

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++ 了:

1-2-1

使用 g++ -v 可以查看到当前编译器版本:

1-2-2

如果你最后一行中的 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 中进行实现。

在 /home/shiyanlou/ 目录下新建 web_service 文件夹,并在文件夹下新建 server_base.hpp 文件,向文件中写入如下代码:

//
// server_base.hpp
//
#ifndef SERVER_BASE_HPP
#define    SERVER_BASE_HPP

#include <boost/asio.hpp>

#include <regex>
#include <unordered_map>
#include <thread>

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_type> socket) const;
    };

    template<typename socket_type>
    class Server : public ServerBase<socket_type> {};
}
#endif /* SERVER_BASE_HPP */

四、设计 ServerBase

实现一个 Web 服务器,最重要的就是对来自客户端的请求信息进行解析,为此,我们需要在 ShiyanlouWeb 命名空间中定义一个 Request 结构体:

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;
    };
}

这个结构体用于解析请求,如请求方法,请求路径,HTTP 版本等信息。同时,并定义一个 std::istream 指针来保存请求体中包含的内容,由于我们并不关心 header 中信息的顺序,所以可以考虑使用 std::unordered_map 来指定一个无序容器,保存 header。此外,由于还需要对请求路径进行解析,我们不妨用正则表达式来处理这部分的解析。

在定义好请求后,我们还需要考虑服务器资源的类型。这个资源类型,决定了我们如何让别人使用我们的库,首先我们定义资源类型:

namespace ShiyanlouWeb {
    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<resource_type::iterator> 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 作用域内定义:

protected:
    boost::asio::io_service m_io_service;
    boost::asio::ip::tcp::endpoint endpoint;
    boost::asio::ip::tcp::acceptor acceptor;

单个线程的服务器注定是鸡肋的,所以我们不妨在内部实现一个线程池,所以我们可以继续定义:

protected:
    size_t num_threads;
    std::vector<std::thread> threads;

整个 ServerBase 应该在被构造时完成一些关键成员的初始化,对于 endpoint 我们可以将其通过tcp::v4()及申明的端口号进行初始化,完成后,在将 io_service 对象和 endpoint 共同交给 acceptor 进行初始化,因此我们有构造函数:

public:
    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 <boost/asio.hpp>

#include <regex>
#include <unordered_map>
#include <thread>

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<resource_type::iterator> all_resources;

        // 需要不同类型的服务器实现这个方法
        virtual void accept() {}

        // 处理请求和应答
        void process_request_and_respond(std::shared_ptr<socket_type> socket) const;
    };

    template<typename socket_type>
    class Server : public ServerBase<socket_type> {};
}
#endif /* SERVER_BASE_HPP */

五、实现 ServerBase

真正要实现的只有两个方法:

  1. void ServerBase::start()
  2. void ServerBase::process_request_and_respond()

首先来实现 start()。实现 start() 时,我们要将考虑下面几个问题:

  1. 默认资源应该在最后被添加到 all_resources 中,当我们处理请求路径时,应该先处理好所有的非特殊路径,当找不到匹配请求路径时,再使用默认的请求资源。
  2. 当指定启用多个线程时,线程启动后需要让线程各自等待,直到整个请求应答过程结束
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;c<num_threads;c++) {
        threads.emplace_back([this](){
            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_type> socket) const {
    // 为 async_read_untile() 创建新的读缓存
    // shared_ptr 用于传递临时对象给匿名函数
    // 会被推导为 std::shared_ptr<boost::asio::streambuf>
    auto read_buffer = std::make_shared<boost::asio::streambuf>();

    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<Request> 类型
            auto request = std::make_shared<Request>();

            // 接下来要将 stream 中的请求信息进行解析,然后保存到 request 对象中
            ……
    });
}

当我们通过 read_buffer 拿到 istream 对象后,就需要对这个这些信息进行解析,然后保存到 request 中,为此,我们不妨增加一个 parse_request() 方法:

protected:
    // 解析请求
    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_type> socket) const {
    auto read_buffer = std::make_shared<boost::asio::streambuf>();
    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<Request> 类型
            auto request = std::make_shared<Request>();
            *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() 方法:

protected:
    // 应答
    void respond(std::shared_ptr<socket_type> socket, std::shared_ptr<Request> 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<boost::asio::streambuf>
                    auto write_buffer = std::make_shared<boost::asio::streambuf>();
                    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;
                }
            }
        }
    }

六、实现 HTTP Server

当我们实现完报头解析、请求应答这两个重要的逻辑之后,剩下的,就是对针对不同类型的服务器实现不同的 accept() 方法了。

在 Boost 中,HTTP 类型就是普通的 socket 类型(boost::asio::ip::tcp::socket)。为此,我们可以通过以下不到四十行代码简单实现 HTTP 服务器,在 web_server 文件夹下新建 server_http.hpp 文件:

//
// 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<HTTP> : public ServerBase<HTTP> {
    public:
        // 通过端口号、线程数来构造 Web 服务器, HTTP 服务器比较简单,不需要做相关配置文件的初始化
        Server(unsigned short port, size_t num_threads=1) :
            ServerBase<HTTP>::ServerBase(port, num_threads) {};
    private:
        // 实现 accept() 方法
        void accept() {
            // 为当前连接创建一个新的 socket
            // Shared_ptr 用于传递临时对象给匿名函数
            // socket 会被推导为 std::shared_ptr<HTTP> 类型
            auto socket = std::make_shared<HTTP>(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 */

七、编写测试

现在我们可以来正式使用我们的框架了。到目前为止,我们一共创建了下面的这些文件:

├── server_base.hpp
└── server_http.hpp

我们的 HTTP Web 框架就只有这两个核心文件。

下面我们可以基于我们的 Web 框架开发一个 Web 服务器了:

首先,在 web_server 文件夹下创建 main_http.cpp 文件,在其中实现 main 逻辑:

//
// main_http.cpp
// web_server
// created by changkun at shiyanlou.com
//
#include <iostream>
#include "server_http.hpp"
#include "handler.hpp"

using namespace ShiyanlouWeb;

int main() {
    // HTTP 服务运行在 12345 端口,并启用四个线程
    Server<HTTP> server(12345, 4);
    std::cout << "Server starting at port: 12345" << std::endl;
    start_server<Server<HTTP>>(server);
    return 0;
}

在这个逻辑中,有 start_server<T>() 这个方法,传递了一个 Server<HTTP> 对象。 而 handler.hpp 则负责实现我们整个 HTTP Web 服务器实例的逻辑。

在开发这个处理逻辑的时候,我们之前提到的框架资源类型定义了我们向外提供的接口,使用形式如下所示:

// 处理访问 /info 的 GET 请求, 返回请求的信息
server.resource["^/info/?$"]["GET"] = [](ostream& response, Request& request) {
    // 处理请求及资源
    // ...
};

为了测试 GET 请求和 POST 请求,我们先在 web_server 文件夹下创建一个 www 文件夹来存放我们的 Web 资源,并创建 index.html 文件作为首页:

<!-- www/index.html -->
<html>
    <head>
        <title>Shiyanlou Web Server Test</title>
    </head>
    <body>
        Hello world in index.html.
    </body>
</html>

在 web_server 文件夹下创建 handler.hpp 文件,我们可以编写下面的服务器测试代码:

//
// handler.hpp
// web_server
// created by changkun at shiyanlou.com
//

#include "server_base.hpp"
#include <fstream>

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 << "<h1>Request:</h1>";
        content_stream << request.method << " " << request.path << " HTTP/" << request.http_version << "<br>";
        for(auto& header: request.header) {
            content_stream << header.first << ": " << header.second << "<br>";
        }

        // 获得 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
└── www
    └── index.html

由于我们使用了 boost,所以在编译时会链接 boost 库,但是当前的环境中缺少相应的二进制文件,因此手动安装:

$ sudo apt-get update
$ sudo apt-get install libboost-system-dev -y # 提供 libboost_system 文件

如果直接使用编译命令编译,会出现指令过长的情况,我们可以在 web_server 文件夹下编写一个 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 发送的字符串。

2-8-1

九、总结

本节实验我们实现了服务器除开建立 TCP 连接具体实现外的 ServerBase 基类,并从此类继承出了 Server<HTTP> 子类,实现了 HTTP 服务器框架,同时,基于我们编写的框架,我们开发出了一个简易的 HTTP Web 服务器。在整个过程中,我们用到了大量 C++11 和 Boost Asio 的相关知识。在下一节实验中,我们将据此进一步实现 HTTPS 服务器框架,并编写启用 HTTPS 服务器。

HTTPS 的原理及其 Web 框架的设计与实现

一、概述

项目介绍

服务器开发中 Web 服务是一个基本的代码单元,将服务端的请求和响应部分的逻辑抽象出来形成框架,能够做到最高级别的框架级代码复用。本次项目将综合使用 C++11 及 Boost 中的 Asio 实现 HTTP 和 HTTPS 的服务器框架。

项目涉及的知识点

  • C++基本知识
    • 面向对象
    • 模板
    • 命名空间
    • 常用 IO 库
  • C++11 相关
    • lambda expression
    • std::shared_ptr
    • std::make_shared
    • std::unordered_map
    • std::regex
    • std::smatch
    • std::regex_match
    • std::function
    • std::thread
  • Boost Asio 相关
    • boost::asio::io_service
    • boost::asio::ip::tcp::socket
    • boost::asio::ip::tcp::v4()
    • boost::asio::ip::tcp::endpoint
    • boost::asio::ip::tcp::acceptor
    • boost::asio::streambuf
    • boost::asio::async_read
    • boost::asio::async_read_until
    • boost::asio::async_write
    • boost::asio::transfer_exactly
    • boost::asio::ssl::stream
    • boost::asio::ssl::stream_base::server
    • boost::asio::ssl::context
    • boost::asio::ssl::context::sslv23
    • boost::asio::ssl::context::pem
    • boost::system::error_code

二、编译环境提示

本次实验中的代码使用了 C++11 标准库中的正则表达式库,在 g++ 4.9 之前, regex 库并不支持 ECMAScript 的正则语法,因此需要将 g++ 版本升级至 4.9 以上。

// 下面的这段代码可以测试你的编译器对正则表达式的支持情况
#include <iostream>
#include <regex>

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++ 了:

1-2-1

使用 g++ -v 可以查看到当前编译器版本:

1-2-2

如果你最后一行中的 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

三、实现 HTTPS 框架

在上一节实验中,我们已经实现了 HTTP 的框架,利用这个框架,我们便能更加方便的进行框架级的复用。Boost Asio 包含了一个类及类模板用于对基本的 SSL 进行支持,这个类使我们实现 HTTPS 服务器成为可能。从实现上来看,我们只需要对已经存在的流再进行一层加密封装,比如加密 TCP Socket。这个过程异常的简单,我们只需稍加利用即可实现整个HTTPS 的框架,在 web_server 文件夹下新建 server_https.hpp 文件:

//
// server_https.hpp
// web_server
// created by changkun at shiyanlou.com
//

#ifndef SERVER_HTTPS_HPP
#define    SERVER_HTTPS_HPP

#include "server_http.hpp"
#include <boost/asio/ssl.hpp>

namespace ShiyanlouWeb {

    // 定义 HTTPS 类型
    typedef boost::asio::ssl::stream<boost::asio::ip::tcp::socket> HTTPS;

    // 定义 HTTPS 服务, 模板类型为 HTTPS
    template<>
    class Server<HTTPS> : public ServerBase<HTTPS> {
    public:
        // 一个 HTTPS 的服务器比 HTTP 服务器多增加了两个参数,一个是证书文件,另一个是私钥文件
        Server(unsigned short port, size_t num_threads,
               const std::string& cert_file, const std::string& private_key_file) :
          ServerBase<HTTPS>::ServerBase(port, num_threads),
          context(boost::asio::ssl::context::sslv23) {
            // 使用证书文件
            context.use_certificate_chain_file(cert_file);
            // 使私钥文件, 相比之下需要多传入一个参数来指明文件的格式
            context.use_private_key_file(private_key_file, boost::asio::ssl::context::pem);
        }

    private:
        // 和 HTTP 服务器相比,需要多定义一个 ssl context 对象
        boost::asio::ssl::context context;

        // HTTPS 服务器和 HTTP 服务器相比
        // 其区别在于对 socket 对象的构造方式有所不同
        // HTTPS 会在 socket 这一步中对 IO 流进行加密
        // 因此实现的 accept() 方法需要对 socket 用 ssl context 初始化
        void accept() {
            // 为当前连接创建一个新的 socket
            // Shared_ptr 用于传递临时对象给匿名函数
            // socket 类型会被推导为: std::shared_ptr<HTTPS>
            auto socket = std::make_shared<HTTPS>(m_io_service, context);

            acceptor.async_accept(
                (*socket).lowest_layer(),
                [this, socket](const boost::system::error_code& ec) {
                    // 立即启动并接受一个新连接
                    accept();

                    // 处理错误
                    if(!ec) {
                        (*socket).async_handshake(boost::asio::ssl::stream_base::server,
                            [this, socket](const boost::system::error_code& ec) {
                            if(!ec) process_request_and_respond(socket);
                        });
                    }
            });
        }
    };
}

#endif    /* SERVER_HTTPS_HPP */

在上面整个过程中,我们仅仅只是重新实现了 accept() 方法,将启用一个 HTTPS 服务器需要的两个文件传递给了 Boost Asio,就完成了整个服务器框架。

四、开发 HTTPS 服务器

我们已经在上一节实验中写过了 handler.hpp,这个文件中实现了服务器资源的访问和处理逻辑。而这部分逻辑,本质上是独立于服务器类型而存在的,因此我们根本不需要在进行任何开发,只需将 main_http.cpp 中的服务器类型修改就能获得一个完整的 HTTPS 服务器,在 web_server文件夹下新建 main_https.cpp 文件:

//
// main_https.cpp
// web_server
// created by changkun at shiyanlou.com
//

#include "server_https.hpp"
#include "handler.hpp"
using namespace ShiyanlouWeb;

int main() {
    //HTTPS 服务运行在 12345 端口,并启用四个线程
    Server<HTTPS> server(12345, 4, "server.crt", "server.key");
    start_server<Server<HTTPS>>(server);
    return 0;
}

在这个服务器上,我们额外传入了 HTTPS 服务器需要的证书和秘钥文件。

为了编译我们的 HTTPS 服务器,在 Makefile 中增加对 HTTPS 服务器的编译选项:

#
# Makefile
# web_server
#
# created by changkun at shiyanlou.com
#

CXX = g++
EXEC_HTTP = server_http
EXEC_HTTPS = server_https

SOURCE_HTTP = main_http.cpp
SOURCE_HTTPS = main_https.cpp

OBJECTS_HTTP = main_http.o
OBJECTS_HTTPS =  main_https.o

LDFLAGS_COMMON = -std=c++11 -O3 -pthread -lboost_system
LDFLAGS_HTTP =
LDFLAGS_HTTPS = -lssl -lcrypto

LPATH_COMMON = -I/usr/include/boost
LPATH_HTTP =
LPATH_HTTPS = -I/usr/include/openssl

LLIB_COMMON = -L/usr/lib

all:
    make http
    make https

http:
    $(CXX) $(SOURCE_HTTP) $(LDFLAGS_COMMON) $(LDFLAGS_HTTP) $(LPATH_COMMON) $(LPATH_HTTP) $(LLIB_COMMON) $(LLIB_HTTP) -o $(EXEC_HTTP)
https:
    $(CXX) $(SOURCE_HTTPS) $(LDFLAGS_COMMON) $(LDFLAGS_HTTPS) $(LPATH_COMMON) $(LPATH_HTTPS) $(LLIB_COMMON) $(LLIB_HTTPS) -o $(EXEC_HTTPS)

clean:
    rm -f $(EXEC_HTTP) $(EXEC_HTTPS) *.o

这时,我们的整个目录树为:

├── Makefile
├── handler.hpp
├── main_http.cpp
├── main_https.cpp
├── server_base.hpp
├── server_http.hpp
├── server_https.hpp
└── www
    └── index.html

现在,我们可以:

  1. 使用 make 一次性编译 http 和 https 服务器;
  2. 使用 make http 单独编译 http 服务器;
  3. 使用 make https 单独编译 https 服务器.

3-4-1

完成了编译后还不够,我们还需要对创建 HTTPS 服务器所需的证书。

五、创建证书文件

第一步:生成私钥

openssl 工具包提供了一个生成 RSA 私钥和 CSR(Certificate Signing Request) 文件的工具。这使得我们可以将其用于生成自签名的证书,从而用于供给 HTTPS 服务器使用。

首先,就是要生成 RSA 私钥。我们生成一个 1024 位的 RSA 秘钥,并使用三重 DES 加密方式,并按照 PEM 格式存储(在库中我们指定了私钥的格式是boost::asio::ssl::context::pem):

openssl genrsa -des3 -out server.key 1024

如图所示,在产生 server.key 时,我们还被要求设置密码,这个密码保护了当别人尝试访问这个私钥时,需要提供密码(作为演示,不妨设置成 123456):

3-5-1

完成后,可以看到产生了 server.key 这个文件。

第二步:生成 CSR

私钥生成后,就可以据此生成一个 CSR 文件了:

openssl req -new -key server.key -out server.csr

在生成 CSR 文件的过程中,会被要求输入刚才我们设置的保护密码,同时还需要输入一些相关的信息,例如这个证书会被用在哪个域名下。最后会要求设置一个 challenge passwrod,通常不去设置这个密码。如图:

3-5-2

第三步:从秘钥中移除密码

如果证书有密码,那么每次使用证书时都讲需要输入一次密码,这不是很方便,况且,秘钥证书位于我们服务器上,不太容易被泄露,因此我们可以将秘钥中的密码移除,首先我们先保存一份秘钥的备份:

cp server.key server.key.old
openssl rsa -in server.key.old -out server.key

3-5-3

第四步:生成自签名证书

最后,生成一个自签名的证书,并设置证书的过期时间为一年:

openssl x509 -req -days 365 -in server.csr -signkey server.key -out server.crt

3-5-4

至此,我们便完成了所有的步骤,现在我们可以运行服务器:

./server_https

然后在浏览器中访问现在这个运行在 12345 端口的 https 服务器了,输入:https://localhost:12345

这时,我们会看到浏览器正在告诉我们这个链接不安全。

3-5-5

这是由于我们的证书是自签名的产生的原因。一般情况下,自签名的 SSL 证书可以随意的签发,没有第三方监督的审核,并不能收到浏览器的信任。这就非常容易造成伪造证书的中间人攻击,从而导致劫持 SSL 加密流量。

我们刚才在创建证书的时候,指定了这个证书会被用于 shiyanlou.com 这个域名,而实际上我们在访问时,访问的 URL 是 localhost,这时浏览器识别到这个不同,也就阻止了这次连接。

为了测试,我们可以将本次连接添加新人列表中,增加一个安全例外:

3-5-6

这样我们就能看到使用 HTTPS 访问到的资源内容了:

3-5-7

可惜的是,我们依然不能做到像『正经』厂商一样,让那一把小锁变成绿色:

3-5-8

原因就如同之前我们所提到的那样,SSL 证书受到第三方监管,浏览器信任的证书一般来自国外的几个指明 SSL 证书签发商,而这种证书的签发往往需要向签发商支付一定的费用,虽然也有诸如 StartSSL 这样的提供免费 SSL 证书的签发商,但由于我们没有域名进行测试,这里就不再赘述了。

六、总结

经过本次项目,我们走过了很多艰难的历程。首先,我们基于 C++11 和 Boost Asio 的诸多特性,开发了一个 HTTP 服务器的 Web 框架,为了测试我们的框架,我们编写了自己的 HTTP 服务器。

我们的设计非常巧妙,在完成 HTTP 服务器 Web 框架和相关测试代码后,进一步扩展为 HTTPS 时,只使用了极少的代码量便完成了整个框架的开发。

我们的开发的框架一共包含三个文件:

  1. server_base.hpp
  2. server_http.hpp
  3. server_https.hpp

而我们基于这个框架,开发了简易的 http 和 https 的 web 服务器,但我们依然复用了服务器实际逻辑的代码,写在了 handler.hpp 之中。此外,我们基于这套框架实现的 http 和 https 服务器在本质上,只有一行代码的不同:

// http server:
Server<HTTP> server(12345, 4);
// https server:
Server<HTTPS> server(12345, 4, "server.crt", "server.key");

作为参考,这里附上本次项目中全部的代码:

wget http://labfile.oss.aliyuncs.com/courses/568/web_server.zip

值得一提的是,里面没有包含 SSL 证书,你需要自己手动创建它们。

七、参考资料

发布了114 篇原创文章 · 获赞 98 · 访问量 26万+

猜你喜欢

转载自blog.csdn.net/github_39533414/article/details/84898371
今日推荐