跳转至

开始使用 LibcURL

本节我们会介绍 LibcURL 的基础使用方法。

Hello LibcURL!

下面的这个程序将 http://localhost:8000 的内容输出到 stdout

hello.c
#include <curl/curl.h>

static const char *const URL = "http://localhost:8000";

int main(void) {
  // 全局初始化 curl,此前不应调用任何 libcurl 函数
  curl_global_init(CURL_GLOBAL_ALL);

  CURL *handle = curl_easy_init();            // 创建请求
  curl_easy_setopt(handle, CURLOPT_URL, URL); // 设置请求 URL
  curl_easy_perform(handle);                  // 发送请求
  curl_easy_cleanup(handle);                  // 释放资源
  handle = NULL;
  // 全局释放 curl 资源,此后不应调用任何 libcurl 函数
  curl_global_cleanup();
}

任何 libcurl 程序都离不开以下四个函数。

curl/curl.h
CURLcode curl_global_init(long flags);

curl/easy.h
1
2
3
CURL *curl_easy_init(void);
CURLcode curl_easy_setopt(CURL *curl, CURLoption option, ...);
CURLcode curl_easy_perform(CURL *curl);

在使用任何 libcurl 函数前,一定要调用 curl_global_init()。这个函数在程序的整个生命周期内只需调用一次,多次调用的作用与一次调用相同。它的参数告诉 libcurl 应该初始化哪些资源,而且总应该是 CURL_GLOBAL_ALL,除非你有正当理由打算操纵 libcurl 的内部行为。

Warning

如果没有全局初始化,那么在第一次调用 curl_easy_init() 时会以默认值 CURL_GLOBAL_DEFAULT 全局初始化。虽然这个值目前也被定义为 CURL_GLOBAL_ALL,但不要依赖于这个行为。某些版本的 libcurl 中,curl_global_init() 不是线程安全的(你可能会很惊讶,但是 macOS 系统自带的 curl 就不是;可以用 curl-config --feature | grep threadsafe 来检查),在多线程程序中可能产生错误。具体请查阅 curl_global_init() 的 manpage。

调用 curl_easy_init() 创建一个 handle。一个 handle 可以用于多个(不同时的)请求。这样也能充分地利用 Keep-Alive 机制。

通过 curl_easy_setopt() 设置请求参数,类似 Socket 编程中的 setsockopt()。与之根据 option_name 确定参数一样,curl_easy_setopt() 根据 option 确定参数,不同之处在于 setsockopt() 统一使用 const void * 规避类型系统,而 curl_easy_setopt() 采用可变参数。因此在 curl/curl.h 中存在另一个同名宏定义以限制参数数量(看看最末尾)。后文中会详细介绍一些常用设置项。

Note

任何请求都应设置 CURLOPT_URL 或者 CURLOPT_CURLU(不然呢?)

对于 CURLOPT_URL,libcurl 会将提供的 URL 在内部复制一份,因此用户不需要在整个请求周期中保持 URL 指向地址的有效性,这在使用临时 buffer 时很有用。大多数其他字符串型设置都有这个设定,但还是记得查查 manpage。我们以后不会强调这个设定,除了那些不遵循这个设定的设置。

那么,CURLOPT_CURLU 是什么?如果不好马上合成一个完整的 URL(比如你有成吨的 GET 参数),就可以用 CURLU * 来一部分一部分地构造 URL。之后介绍。

Tip

使用 man(或 info)可以更详细地查看每个单独选项的说明。

例如,当执行 man CURLOPT_URL 时,manpage 将给出此时 curl_easy_setopt() 第三个参数的正确类型:

CURLOPT_URL(3)
CURLcode curl_easy_setopt(CURL *handle, CURLOPT_URL, char *URL);

最后,使用 curl_easy_perform() 实际发送请求。默认情况下,它会包揽创建连接、发送数据、接收数据与处理数据的全部过程(因此,当它返回时,所有数据已经全部处理完毕)。这可以通过 CURLOPT_CONNECT_ONLY 参数改变,我们后面会介绍如何实现。同时,在默认情况下,它会将接收到的数据全部输出到 stdout。我们马上就会看到,可以通过 CURLOPT_WRITEFUNCTION 自定义此行为。

为什么叫做 curl_easy

libcurl 提供了两套接口:Easy 与 Multi。

Easy 顾名思义是 libcurl 提供的易用接口(并不意味着在功能上有所妥协),它们全部以 curl_easy 打头,handle 类型 CURL *。Easy 接口是同步(阻塞)的,适用于快速上手,或只需要进行少量请求的情况(指请求个数而非数据量)。

Multi 是用于管理多个 Easy handle 的接口,支持单线程异步(非阻塞)操作,支持并发请求,它们以 curl_multi 打头,handle 类型 CURLM *。Multi 也分两种编程风格:select() 式与事件驱动式(称作 multi_socket)。

Note

下文中,我们默认以 Easy 接口举例。Multi 接口将在以后单独介绍。

使用 callback 接收数据

总览页中的示例程序演示了如何使用 callback 接收数据。在此我们给出一个更精简的例子。

callback_write.c
#include <curl/curl.h>

static const char *const URL = "http://localhost:8000";

size_t recv_cb(char *ptr, size_t size, size_t nmemb, void *userdata) {
  fprintf(stderr, "[+] ptr = %p, size = %zu, nmemb = %zu, userdata = %p\n",
          ptr, size, nmemb, userdata);
  return fwrite(ptr, size, nmemb, (FILE *)userdata);
}

int main(void) {
  curl_global_init(CURL_GLOBAL_ALL);
  FILE *my_stream = fopen("/tmp/curlout", "w");

  CURL *handle = curl_easy_init();

  curl_easy_setopt(handle, CURLOPT_URL, URL);
  // my_stream 将成为 recv_cb 的 userdata 参数
  curl_easy_setopt(handle, CURLOPT_WRITEDATA, my_stream);
  curl_easy_setopt(handle, CURLOPT_WRITEFUNCTION, recv_cb);
  curl_easy_perform(handle);

  curl_easy_cleanup(handle);
  handle = NULL;
  fclose(my_stream);
  curl_global_cleanup();
}

此处重要的两个参数是 CURLOPT_WRITEDATACURLOPT_WRITEFUNCTION

使用 CURLOPT_WRITEFUNCTION 设置自定义 callback。使用 CURLOPT_WRITEDATA 设置的指针成为 callback 的第四个参数。

在这个程序中,我们的 callback 只是简单的调用 fwrite() 将数据写入文件。实际上你可能已经发现,recv_cb() 的函数签名与 fwrite() 是一样的(除了指针类型,但这对于 callback 不重要)。也就是说,在此例中,可以直接将 fwrite() 作为 CURLOPT_WRITEFUNCTION 的参数。

Warning

callback 的返回值是重要的。需要是 size_t 类型,而且应当等于 size * nmemb。否则,libcurl 将认为写数据时发生错误,curl_easy_perform() 调用将返回 CURLE_WRITE_ERROR。(libcurl 保证 size 永远等于 1。)

如果你正在 C++ 中使用 libcurl,请注意在设置 callback 时不能使用 Lambda 表达式与仿函数。即使你的 Lambda 不捕获任何变量,也需要先转换到函数指针。否则在执行 curl_easy_perform() 时程序会崩溃。

实际上,在这个例子中,也可以完全不设置 callback。还记得我们说过,如果没有额外设置,会将数据输出到 stdout 吗?也就是说,默认的 callback 就是 fwrite(),而默认的第四个参数也就是 stdout(自己试试输出它的地址)。如果不设置自定义 callback,那么这时候 CURLOPT_WRITEDATA 就必须是一个 FILE * 指针。此时 libcurl 表现得如同用 fwrite() 将数据写到你提供的 FILE * 中。

设置请求标头

使用 CURLOPT_HTTPHEADER 设置自定义请求标头。它的参数是类型 struct curl_slist * 的链表指针。

req_header.c
#include <curl/curl.h>

static const char *const URL = "http://localhost:8000/path?user=myself";

int main(void) {
  curl_global_init(CURL_GLOBAL_ALL);

  // 创建 curl_slist 类型的链表,用于存储标头
  struct curl_slist *headers =
      curl_slist_append(NULL, "Accept-Charset: utf-8");
  headers = curl_slist_append(headers, "X-AppToken: HLyMNQCn");
  headers = curl_slist_append(headers, "User-Agent: Ciallo/0.7.21");
  headers = curl_slist_append(headers, "Referer: http://127.0.0.1:8000");

  CURL *handle = curl_easy_init();
  curl_easy_setopt(handle, CURLOPT_URL, URL);
  curl_easy_setopt(handle, CURLOPT_REFERER, "http://localhost:8000");
  curl_easy_setopt(handle, CURLOPT_HTTPHEADER, headers);
  curl_easy_perform(handle);

  curl_easy_cleanup(handle);
  handle = NULL;
  curl_slist_free_all(headers);
  headers = NULL;
  curl_global_cleanup();
}

通过调用 curl_slist_append() 向链表中添加字符串。若第一个参数为 NULL 则创建链表。记得用 curl_slist_free_all() 释放链表。

如果 CURLOPT_HTTPHEADER 的参数为 NULL,会清除自定义标头。

上面的程序会产生如下的请求:

HTTP
1
2
3
4
5
6
7
GET /path?user=myself HTTP/1.1
Host: localhost:8000
Accept: */*
Accept-Charset: utf-8
X-AppToken: HLyMNQCn
User-Agent: Ciallo/0.7.21
Referer: http://127.0.0.1:8000

Note

libcurl 还提供了一些设置某些特定标头的选项(如 CURLOPT_USERAGENTCURLOPT_COOKIE 等)。若其对应的标头在链表中同时存在,则使用链表中的标头。

没有专门的用于从链表中删除元素的函数,也不存在诸如 curl_slist_dup() 这种方便的函数。如果你有两个标头非常类似但不同的请求,只能分别构造两个列表。

对链表来说,字符串的有效性不需要保持,链表会复制一份。但是,对于 handle 来说,链表本身是不会复制的:必须在整个 handle 的请求周期中保持标头链表的有效性。

curl_slist_append() 的 manpage 中提到,函数会在出错时返回 NULL。虽然一般来说不会有什么问题,但如果你有这方面的担心,要注意防止返回的 NULL 值覆盖掉原本的指针。

Warning

不要在标头字符串的最后带上 \r\n

如果我们观察上面产生的请求,会发现 libcurl 自动帮我们加上了 Accept 标头。当然,这很好,但是假如说我们不希望 libcurl 这样做呢?这也可以。只需给冒号后面留空即可,像这样:

headers = curl_slist_append(headers, "Accept:");
HTTP
1
2
3
4
5
6
GET /path?user=myself HTTP/1.1
Host: localhost:8000
Accept-Charset: utf-8
X-AppToken: HLyMNQCn
User-Agent: Ciallo/0.7.21
Referer: http://127.0.0.1:8000

复制请求

使用 curl_easy_duphandle() 复制一个 handle。下面的程序将先后请求 URL 两次,而其中的一个会输出详细信息,正如在命令行使用 curl -v 的结果一样。

duphandle.c
#include <curl/curl.h>

static const char *const URL = "http://localhost:8000";

int main(void) {
  curl_global_init(CURL_GLOBAL_ALL);

  struct curl_slist *slist =
      curl_slist_append(NULL, "User-Agent: Ciallo/0.7.21");
  CURL *handle = curl_easy_init();
  curl_easy_setopt(handle, CURLOPT_URL, URL);
  curl_easy_setopt(handle, CURLOPT_HTTPHEADER, slist);
  CURL *another = curl_easy_duphandle(handle); // 复制一个 handle
  curl_easy_setopt(another, CURLOPT_VERBOSE, 1L);
  curl_easy_perform(handle);
  curl_easy_perform(another);

  curl_easy_cleanup(handle);
  handle = NULL;
  curl_easy_cleanup(another);
  another = NULL;
  curl_slist_free_all(slist);
  slist = NULL;
  curl_global_cleanup();
}

复制出的 handle 也需要使用 curl_easy_cleanup() 清理。所有 libcurl 内部复制的字符串设置也会复制一份,所有仅指向而没有复制的设置也同样只是复制了指向,它们的指针有效性需要保持。

不仅仅是 GET

使用 POST 方法

接下来我们来看看,怎么用 libcurl 向服务端发送数据。

post.c
#include <curl/curl.h>

static const char *const URL = "http://localhost:8000";

int main(void) {
  curl_global_init(CURL_GLOBAL_ALL);

  const char post_data[] = "{\"secret\":\"dTdrvDQ5\"}";
  struct curl_slist *headers =
      curl_slist_append(NULL, "Content-Type: application/json");

  CURL *handle = curl_easy_init();
  curl_easy_setopt(handle, CURLOPT_URL, URL);
  curl_easy_setopt(handle, CURLOPT_POST, 1L);
  curl_easy_setopt(handle, CURLOPT_POSTFIELDS, post_data);
  curl_easy_setopt(handle, CURLOPT_HTTPHEADER, headers);

  curl_easy_perform(handle);
  curl_easy_cleanup(handle);
  handle = NULL;
  curl_slist_free_all(headers);
  headers = NULL;
  curl_global_cleanup();
}

注意 Content-Type 标头需要自己设置。使用 CURLOPT_POSTFIELDS 来设置数据的指针。此时,Content-Length 在内部由 strlen() 计算得出。

Note

CURLOPT_POSTFIELDS 不复制数据,所以指针有效性需要保持。如果需要 libcurl 复制数据,请把它换成 CURLOPT_COPYPOSTFIELDS

特别注意在这种情况下,设置数据大小的顺序就是必要的了:如果你先设置 CURLOPT_COPYPOSTFIELDS,此时会立刻根据 strlen() 的计算结果复制数据,后续再使用 CURLOPT_POSTFIELDSIZE 也就没有作用了。

假设你要发送二进制数据怎么办?很好办:设置 CURLOPT_POSTFIELDSIZE。设为 -1 则代表使用 strlen()。如果你的数据量超过了 2 GB,那么应转而使用 CURLOPT_POSTFIELDSIZE_LARGE。(小于 2 GB 的数据也可以用它,那为什么 libcurl 要这样设计呢?也许是历史原因。)

1
2
3
const uint8_t data[4] = {0, 24, 0, 81};
curl_easy_setopt(handle, CURLOPT_POSTFIELDSIZE_LARGE, 4);
curl_easy_setopt(handle, CURLOPT_COPYPOSTFIELDS, data);

其他方法的请求

对于其他类型的请求,情况会变得稍微棘手一些。

对于 PUTDELETEOPTIONS 等,请使用 CURLOPT_CUSTOMREQUEST 显式用字符串设置。

curl_easy_setopt(handle, CURLOPT_CUSTOMREQUEST, "PUT");

但是,对于 HEAD,正确的设置项是 CURLOPT_NOBODY

curl_easy_setopt(handle, CURLOPT_NOBODY, 1L);

Note

你可能会注意到,存在一个 CURLOPT_PUT 设置项,并且想用它来反驳我:“这不是有专门设置 PUT 的选项吗?”你说得对,但是你也许有所不知:在 libcurl 7.12.1 版本后,这个设置项被标为已废弃——manpage 是这么说的,并且建议换用 CURLOPT_UPLOAD。这里涉及到一些比较错综复杂的关系。我们会在下一节理清,目前就先承认它好了。

增量构造 URL

我们之前还提到了神秘选项 CURLOPT_CURLU——其实没什么神秘的,只是把一个 const char * 类型的 URL 换成了 CURLU * 类型的 URL 而已。那么很自然地,我们会问:“这又是什么新东西?”例如如果我们需要发一个查询参数需要进行 URL 编码的请求,如果不想手动处理那些转义,就可以用 CURLU * 把这种工作交给 libcurl,太贴心了。

curlu_get.c
#include <curl/curl.h>

int main(void) {
  curl_global_init(CURL_GLOBAL_ALL);
  CURL *handle = curl_easy_init();

  CURLU *url = curl_url(); // 创建一个 CURLU 对象
  curl_easy_setopt(handle, CURLOPT_CURLU, url);

  // 既可以直接设置整个 URL...
  curl_url_set(url, CURLUPART_URL, "https://127.0.0.1:0721", 0);
  curl_url_set(url, CURLUPART_HOST, "localhost", 0); // ...也可以分开设置
  curl_url_set(url, CURLUPART_SCHEME, "http", 0); // 改变协议
  curl_url_set(url, CURLUPART_PORT, "8000", 0);   // 改变端口
  curl_url_set(url, CURLUPART_PATH, "/read", 0);  // 加上路径
  // 再加上一些查询参数
  curl_url_set(url, CURLUPART_QUERY, "msg={msg Ciallo!}",
               CURLU_APPENDQUERY | CURLU_URLENCODE);
  curl_url_set(url, CURLUPART_QUERY, "path=/.git/HEAD",
               CURLU_APPENDQUERY | CURLU_URLENCODE);

  curl_easy_perform(handle);
  curl_easy_cleanup(handle);
  handle = NULL;
  curl_url_cleanup(url);
  url = NULL;
  curl_global_cleanup();
}

注意,如果 CURLOPT_URLCURLOPT_CURLU 都设置了,CURLU *const char * 优先级更高。

这份程序产生下面这样的请求。

HTTP
1
2
3
GET /read?msg=%7bmsg+Ciallo%21%7d&path=%2f.git%2fHEAD HTTP/1.1
Host: localhost:8000
Accept: */*

你已经看到了这里的关键函数 curl_url_set()

curl/urlapi.h
1
2
3
4
CURLUcode curl_url_set(CURLU *handle, CURLUPart what,
                       const char *part, unsigned int flags);
CURLUcode curl_url_get(CURLU *handle, CURLUPart what,
                       char **part, unsigned int flags);

CURLUPart 就是我们看到的诸如 CURLUPART_URL 这样的枚举。flags 控制 libcurl 应该怎么处理这部分设置。例如,如果在上面设置查询参数时不加上 CURLU_APPENDQUERY 的话,那么后设置的参数就会覆盖前面设置的参数。同样,如果不使用 CURLU_URLENCODE 的话,那些 URL 特殊字符就不会被编码。你也已经看到,在处理查询参数这方面 libcurl 足够智能,它需要你直接传入 key=value 的形式,而且这第一个等号不会被编码。

相同的,还有 curl_url_get() 用来从 CURLU * 中解析你所需要的部分出来。在这里通过 part 参数获得的字符串必须由你使用 curl_free() 释放掉。

Note

CURLU 并不是 libcurl 的重点,所以它的这些参数与选项并没有排面到能独占自己的一份 manpage 的地步,而是全部收归 curl_url_set()curl_url_get() 下。

curl_url_dup() 可以复制一份 CURLU * 出来。记得释放。

顺便,libcurl 还提供了 curl_easy_escape() 函数,顾名思义它只帮你做 URL 编码的部分,然后你就可以用你最喜欢的 snprintf() 来自己格式化 URL 了。与之相反的,也有 curl_easy_unescape()

评论