libcurl 使用 http2

首先 http2 一定要是加密的,必须要 https 才行,所以第一步要先支持 https 。
如果是一个权威机构认证的证书,那么直接使用正常的请求方式也 OK 的,底层已经处理好了自动可以验证到证书的有效性。然而,如果是我们自己签名的证书底层没有办法自动验证证书,就需要我们自己来解决了。
如果服务器使用的是自签名,libcurl 会报证书无效的错误:
SSL certificate problem: Invalid certificate chain

有两个思路,一是干脆直接不做验证:

curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 0L);
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 0L);

当然,对于强迫症人群来说,我们怕 DNS 被劫持,所以要自己验证一下证书。当然其实也很简单:

curl_easy_setopt(curl, CURLOPT_CAINFO, "<your certificate path>");

只要证书传对就可以了。如果使用 ip 访问,然而证书中指定了主机名,那么会报错。
WARNING: using IP address, SNI is being disabled by the OS.
这种情况如果一定要使用这个证书并且使用不同域名访问的话,可以把域名验证关掉。

curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 0L);

https 可以成功了之后,再看如何发出一个 http2 的消息。
起始只要库的版本没问题并且服务器端支持 http2,那么默认的 https 就被底层默认处理为 http2 消息了。当然,显示的设定一下 http 版本算是一种礼貌吧,而且即使设成2.0如果不被支持的话也会默认转为1.1的。

curl_easy_setopt(curl, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_2);

如果需要使用 http2 的多路传输 pipelining 只需要做一个简单的设置:

curl_multi_setopt(curlm, CURLMOPT_PIPELINING, CURLPIPE_MULTIPLEX);
// 尽可能多路复用 
curl_easy_setopt(curl, CURLOPT_PIPEWAIT, 1L);

关于更多链接复用和 pipelining 的细节,我还没深入了解,之后有空再研究。

关于环境的搭建:
安装支持 http2 的 apache

mac 系统安装支持 http2 的 curl:
使用 brew 安装一个全新的 curl

brew install curl --with-nghttp2

强行将默认的 curl 连接为这个最新版本

brew link curl —force

然后重启终端就可以生效了。
可以试一试

curl -v --http2 https://10.10.10.109/test_http2.php

参考资料
如果不出意外,那么在返回数据中会有 HTTP/2.0 200 那么就说明环境没问题了。

我们在使用 libcurl 库的时候还要注意一下,一定要连接到新编译出的这个版本,如果是系统默认版本是不支持 http2 的。
我用的方法就是在 cmake 脚本简单指定了一下连接路径:

link_directories(/usr/local/Cellar/curl/7.48.0/lib)

毕竟这不是重点,实现功能最重要。

新的功能已经更新到 github 可以直接 点击前往

libcurl 撸记

废话不讲,直奔主题

1. 基本流程
首先在使用这个库的任何操作前,最先进行的一个操作就是全局初始化。当然,相对的,在结束后也需要全局的销毁。这两个函数在开始的时候最好就直接先拍上,肯定不会浪费的。

// 全局初始化 
curl_global_init(CURL_GLOBAL_ALL); 

// your code ... 

// 全局清理 
curl_global_cleanup();

然而,这似乎并不是必须的,从 libcurl 提供的 examples 来看,并不是所有情况都有用这两个函数。具体情况就不太深究了,写上也没什么损失。根据官方资料,每个程序(application)都要执行一次。

2. 同步请求
同步请求就是使用 easy 系列函数,基本的操作流程就是:
初始化 → 设置属性 → 执行 → 获得返回数据 → 销毁
这里每一步都是阻塞式的,perform 这步实际发出 http 请求,收到回复前都会阻塞住。

// 初始化
CURL *handle = curl_easy_init();

// 设置
curl_easy_setopt (handle, CURLOPT_URL, url);
curl_easy_setopt (handle, CURLOPT_HEADER, 0L);
curl_easy_setopt (handle, CURLOPT_NOBODY, 1L);
curl_easy_setopt (handle, CURLOPT_NOSIGNAL, 1L);

// 执行
if (curl_easy_perform (handle) == CURLE_OK) {
    // 获得返回数据
    curl_easy_getinfo (handle, CURLINFO_RESPONSE_CODE, &responseCode);
    curl_easy_getinfo (handle, CURLINFO_CONTENT_LENGTH_DOWNLOAD, &file_length);
}

// 销毁
curl_easy_cleanup (handle);

3. 异步请求

异步请求就要复杂了,需要同时使用 easy 系列函数和 multi 系列函数。
multi 负责管理所有的添加进来的 easy 指针,然而它主要做的工作是异步的把所有加进来的 easy 都 perform 掉。最终取数据的时候,它会返回 easy 的指针,所以取数据还是要使用同步方式的操作。并且,需要自己手动调用 select 来执行异步操作的。
最终可以实现所有操作不会把线程阻塞住。
因为异步,所以流程并不固定,大概可以这样描述:
固定流程:初始化 → 循环[ 开始请求 → 取 fd_set → 执行 select → 取消息 ] → 销毁
添加请求:生成一个 easy 请求句柄 → 将指针添加给 multi
获取返回:通过 multi 取出的消息拿到 easy 指针 → 通过这个 easy 指针读取返回数据
这样生成请求和读取返回信息可以是完全异步完成的。

/******* 固定流程结构 *******/

// 初始化
CURLM* curlm = nullptr;
curlm = curl_multi_init();

// 开始异步请求 (不阻塞)
curl_multi_perform(curlm, &handles);

// 取 fd set
FD_ZERO(&read_fd);
FD_ZERO(&write_fd);
FD_ZERO(&exec_fd);
curl_multi_fdset(curlm, &read_fd, &write_fd, &exec_fd, &max_fd);

// 执行 select
select(max_fd+1, &read_fd, &write_fd, &exec_fd, &T);

// 取消息
CURLMsg* msg;
msg = curl_multi_info_read(curlm, &msgs);

// 销毁
curl_multi_cleanup(curlm);
/******* 添加请求 *******/ 

// 初始化 
CURL* curl = curl_easy_init(); 

/* your code ... */ 

// 添加 
curl_multi_add_handle(curlm, curl);
/******* 取返回的数据 *******/

/* msg = curl_multi_info_read(curlm, &msgs); */

// 取出 easy 指针
CURL *e = msg->easy_handle;

// 读取返回数据
curl_easy_getinfo(e, CURLINFO_PRIVATE, &opt);
curl_easy_getinfo(e, CURLINFO_RESPONSE_CODE, &responseCode);

/* your code ... */

// 清理
curl_multi_remove_handle(curlm, e);
curl_easy_cleanup(e);

4. 返回数据接收
通过 curl_easy_getinfo 只能获取到返回的 response code 不能获得返回的数据。如果需要通过 http 请求获得数据返回或者下载文件的话,需要在 CURL 指针的设置中,挂一个接收数据的函数。

static size_t requestWriteData(void *ptr, size_t size, size_t nmemb, void *stream)
{
    /* your code ... */
}
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, requestWriteData);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, stream);

这个函数的声明是这样的:

/* 回调函数指针的声明 */
typedef size_t (*curl_write_callback)(char *buffer, size_t size, size_t nitems, void *outstream);

buffer 数据指针
size 指针单个偏移的大小
nitems 指针能偏移多少个
outstream 由用户传入的指针 CURLOPT_WRITEDATA
返回值:本次接收的数据大小

然后我们在这个函数中来处理返回来的数据。
这里需要注意的是这个函数不一定是一次性返回全部的数据,所以要妥善处理才行,直到 curl_easy_perform 返回或者 curl_multi_info_read 拿到这个句柄才能认为返回数据已经写入完毕,否则接收到的数据都可能是不完整的。
下面是我下载部分的处理:

static size_t downloadWriteData(void *ptr, size_t size, size_t nmemb, void *stream)
{
    DownloadBlock *block = (DownloadBlock *) stream;
    size_t written = fwrite(ptr, size, nmemb, block->fd);
    return written;
}

不过如果不需要特殊处理,可以不需要 CURLOPT_WRITEFUNCTION 传入一个函数,只需要 CURLOPT_WRITEDATA 传入一个 FILE* ,下载的数据会自动写入文件。(至少我使用的版本是这样的)

5. POST 和 GET
libcurl 默认的模式是 GET ,传参数只需要写在 url 后边就可以了,只要不做特殊的设置,就是以 GET 形式向服务器发请求。
那么如果我们要发 POST 呢?
首先,我们要显示的设置我们这个请求是一个 POST 请求:

curl_easy_setopt(curl, CURLOPT_POST, 1L);

POST 的参数不是接在 url 后边的,而是要单独传递:

curl_easy_setopt(curl, CURLOPT_POSTFIELDS, "a=1&b=22&c=3333");

这样设置好,perform 后就向服务器发送 POST 请求了。

6. 分块下载
使用 http 协议下载文件,数据是绝对顺序传输的,如果客户端或者服务器端在传输过程中发生了一些问题,就会导致 TCP 层重试,导致下载效率降低。所以如果只是直接单纯的顺序传输下载,效果是比较差的。因此同时开多个连接,同时下载文件的不同部分,会对网络的利用率更高一些。
下载文件的一个块,在 http1.1 中引入了 Range 参数。这个参数就指定了这个连接负责下载文件的哪个部分。

/* 100-1000 */ 
sprintf(range, "%ld-%ld", start, end); 
curl_easy_setopt(curl, CURLOPT_RANGE, range);

写文件的部分只需要每个连接各自拿一个文件句柄 seek 到起始位置就可以了。

然而如何让所有连接同时工作起来呢?
有两个选择:1) 每个连接各自使用 curl_easy_perform 发起请求;2) 加入到 multi 中异步请求。
1) 因为是会阻塞,所以单线程是无法完成一起工作的,所以如果选择这个种简单的方式,就需要手动开多个线程来完成。这里需要注意的是每个线程使用自己的 CURL* 基本上也不会出什么乱子。
2) 这种方式会比较省心一些,不用处理复杂的线程同步问题。只是 multi 异步架构起来会稍微复杂。
我个人倾向于选择方案2,因为没有太大必要折腾多线程来处理下载的问题。线程开的再多也并不会使网线带宽变大,所以也没必要花心思处理多线程数据同步问题。
方案2,只要处理好最大连接数,以及下载结束后的收尾,在性能和稳定性方面还是更靠谱一些的。

7. 断点续传
要实现断点续传首先我们应该确定断点,必须知道下载的文件断在了什么地方才能知道从哪里开始继续下载。
这里可以分两种情况来考虑
1) 不分块顺序下载,那么文件已经下载好的部分就是断点。那么下一次只需要取出已下载文件的大小,从这里开始继续下就可以了。这可以不必使用 Range 参数,可以使用 RESUME_FROM 设置起始点。不过对于大文件,不适用分块的话效率会比较低。
2) 分块下载,对于分块下载到同一个文件的情况,读取文件已完成部分大小的方式就显然不可行了。这里就需要借助另外一个文件来记录文件每块的下载进度了。在写被下载文件的同时再写一个记录进度的 log 文件,如果正常完成了下载,就可以将 log 文件删除了,如果没有完成,下一次可以先读取 log 文件,通过 log 文件中的记录继续剩余分块的下载。
那么再写一个 log 文件是不是会有性能问题呢?答案是肯定会多消耗一些磁盘的写带宽,但是对于下载这件事的影响有多大呢?理论上不会有什么影响,毕竟磁盘的写入带宽要远远大于网络传输带宽,所以对实际效果的影响应该是不大的。
据我观察迅雷等下载软件也是会给每个下载的文件多写一个 log 文件的。当然我并不确定它是不是用这个 log 文件作为断点记录。

写在最后
这篇贴出来的代码都是小片段,因为自己撸出来的代码确实挺丑,就不拿出来丢人了。贴出来除了多占篇幅,也起不到太大作用。
我封装了一个 http client 放到了 github 有需要的同学自取。实现了 http post 、http get 以及分块下载和断点续传。
点击前往:ftxHttpClient

RSS
Follow by Email
YouTube
YouTube
Pinterest
fb-share-icon
LinkedIn
Share
VK
Weibo
WeChat
WhatsApp
Reddit
FbMessenger
Copy link
URL has been copied successfully!