废话不讲,直奔主题
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