图 1 会话、连接和请求
由于分布式编程的发展,大多数基于 Windows
® 的现今的应用程序必须能够执行 HTTP 请求。虽然 HTTP 相对简单,但现今的 HTTP 的处理却未必简单。异步处理需要缓冲大量的请求和响应、身份验证、自动代理服务器检测、持久连接等操作。当然,您可以忽略其中的许多问题,但这会影响应用程序的质量,而且模拟 HTTP 不像模拟 TCP 套接字那样简单。那么,C++ 开发人员的任务是什么?
一个常见的误解是,如果希望应用程序访问 Web,则需要使用 Microsoft
® .NET Framework。事实上,使用托管代码的开发人员仍然必须处理许多我刚刚提到的问题,而且具象状态传输 (REST) 等许多新式 Web 服务使用本机代码进行编程也很容易。
在本月的专栏中,我将介绍如何使用 Windows HTTP 服务(又称 WinHTTP)API 实现 HTTP 客户端。此外,在 Windows Vista
® 和 Windows Server
® 2008 中,WinHTTP 支持上载大于 4GB 的文件、改进了基于证书的身份验证,并且还提供了检索源 IP 地址和目标 IP 地址的功能。
WinHTTP 既可提供 C API,又可提供 COM API。最初的 C API 自然较难使用,但是稍微借助一下 C++,它就可以为构建 HTTP 客户端提供功能强大且灵活的 API。此外,它还同时提供同步编程模型和异步编程模型。我将重点介绍异步模型,原因如下:首先,在当今的并发感知领域中,并行编程非常重要。其次,在文档和培训中,通常会过多强调单线程编程,而忽略了对并行编程的介绍。或许有人认为并行编程太简单,不需要专门提供说明性指导。虽然许多开发人员不愿意使用并行编程,但是您很快就会发现它相当自然,甚至很有趣(哦,您对有趣的定义可能与我不同,但不要泄气)!
WinHTTP 概述
虽然在 C API 中并不明显,但 WinHTTP API 实际是在逻辑上作为三个独立对象进行建模:会话、连接和请求。初始化 WinHTTP 需要会话对象。每个应用程序只需要一个会话。会话有助于创建连接对象。您希望与之进行通信的每个 HTTP 服务器均需要一个连接对象,而连接对象也有助于创建单个请求对象。第一次发送请求后就会建立实际的网络连接。
图 1 中描述了这些对象之间的关系。一个会话可能具有多个活动连接,并且一个连接可能包含多个并发请求。
会话对象、连接对象和请求对象可由 HINTERNET 句柄表示。虽然创建这些对象时使用的函数不同,但是如果将各个对象的相应句柄传递给 WinHttpCloseHandle 函数,这些对象都会被损坏。此外,一些函数(如 WinHttpQueryOption 和 WinHttpSetOption)使用句柄查询和设置 WinHTTP 对象支持的各种不同选项。
图 2 列出了 WinHttpHandle 类,可将其用于管理 WinHTTP 句柄中所有三种类型的生存期,以及查询和设置这些句柄上的选项。因此,如果给定一个请求对象,您就可以使用 WINHTTP_OPTION_URL 选项检索其完整 URL:
DWORD length = 0;
request.QueryOption(WINHTTP_OPTION_URL, 0, length);
ASSERT(ERROR_INSUFFICIENT_BUFFER == ::GetLastError());
CString url;
COM_VERIFY(request.QueryOption(WINHTTP_OPTION_URL,
url.GetBufferSetLength(length / sizeof(WCHAR)), length));
url.ReleaseBuffer();
图 2 WinHttpHandle 类
class WinHttpHandle
{
public:
WinHttpHandle() :
m_handle(0)
{}
~WinHttpHandle()
{
Close();
}
bool Attach(HINTERNET handle)
{
ASSERT(0 == m_h);
m_handle = handle;
return 0 != m_handle;
}
HINTERNET Detach()
{
HANDLE handle = m_handle;
m_handle = 0;
return handle;
}
void Close()
{
if (0 != m_handle)
{
VERIFY(::WinHttpCloseHandle(m_handle));
m_handle = 0;
}
}
HRESULT SetOption(DWORD option,
const void* value,
DWORD length)
{
if (!::WinHttpSetOption(m_handle,
option,
const_cast(value),
length))
{
return HRESULT_FROM_WIN32(::GetLastError());
}
return S_OK;
}
HRESULT QueryOption(DWORD option,
void* value,
DWORD& length) const
{
if (!::WinHttpQueryOption(m_handle,
option,
value,
&length))
{
return HRESULT_FROM_WIN32(::GetLastError());
}
return S_OK;
}
HINTERNET m_handle;
};
我在代码段中使用 COM_VERIFY 宏来明确指出函数返回必须要检查的 HRESULT 的位置。您可以使用相应的错误处理代替此操作,不论它是引发异常还是您自行返回 HRESULT 都可以。
会话对象
会话对象是使用 WinHttpOpen 函数创建的。第一个参数指定应用程序的可选代理字符串。接下来的三个参数指定 WinHTTP 如何解析服务器名称。这在控制是直接访问服务器还是通过代理服务器访问服务器时很有用(有关处理代理的详细信息,请参阅以下标题为“
确定代理设置”的侧栏中的内容)。最后一个参数中包含标记,但当前只定义了其中一个标记。WINHTTP_FLAG_ASYNC 指示 WinHTTP 函数将异步执行:
HINTERNET session = ::WinHttpOpen(0, // no agent string
WINHTTP_ACCESS_TYPE_DEFAULT_PROXY,
WINHTTP_NO_PROXY_NAME,
WINHTTP_NO_PROXY_BYPASS,
WINHTTP_FLAG_ASYNC);
图 3 列出了从
图 2 中的 WinHttpHandle 派生的 WinHttpSession 类,WinHttpSession 类可以简化会话对象的创建过程。WinHttpSession 使用某些默认值创建会话,在大多数情况下,这些默认值就足够了。
图 3 WinHttpSession 类
class WinHttpSession : public WinHttpHandle
{
public:
HRESULT Initialize()
{
if (!Attach(::WinHttpOpen(0, // no agent string
WINHTTP_ACCESS_TYPE_DEFAULT_PROXY,
WINHTTP_NO_PROXY_NAME,
WINHTTP_NO_PROXY_BYPASS,
WINHTTP_FLAG_ASYNC)))
{
return HRESULT_FROM_WIN32(::GetLastError());
}
return S_OK;
}
};
连接对象
连接对象是使用 WinHttpConnect 函数创建的。第一个参数指定新连接所属的会话。第二个参数列出服务器的名称或 IP 地址。第三个参数指定服务器的端口号。对于此参数,您还可以使用 INTERNET_DEFAULT_PORT,这样,WinHTTP 会将端口 80 用于常规 HTTP 请求,而将端口 443 用于安全 HTTP 请求 (HTTPS):
HINTERNET connection = ::WinHttpConnect(session,
L"example.com",
INTERNET_DEFAULT_PORT,
0); // reserved
if (0 == connection)
{
// Call GetLastError for error information
}
图 4 中的代码显示了 WinHttpConnection 类,该类也是从 WinHttpHandle 派生而来,可以简化连接对象的创建过程。
图 4 WinHttpConnection 类
class WinHttpConnection : public WinHttpHandle
{
public:
HRESULT Initialize(PCWSTR serverName,
INTERNET_PORT portNumber,
const WinHttpSession& session)
{
if (!Attach(::WinHttpConnect(session.m_handle,
serverName,
portNumber,
0))) // reserved
{
return HRESULT_FROM_WIN32(::GetLastError());
}
return S_OK;
}
};
请求对象
从请求对象开始,事情就变得有趣了。请求对象是使用 WinHttpOpenRequest 函数创建的:
HINTERNET request = ::WinHttpOpenRequest(connection,
0, // use GET as the verb
L"/developers/",
0, // use HTTP version 1.1
WINHTTP_NO_REFERRER,
WINHTTP_DEFAULT_ACCEPT_TYPES,
0); // flags
第一个参数指定请求所属的连接。第二个参数指定请求中使用的 HTTP 谓词;如果该参数为 0,则会假定 GET 请求。第三个参数指定正在请求的资源的名称或相对路径。第四个参数指定要使用的 HTTP 协议版本;如果该参数为 0,则会假定 HTTP 版本 1.1。第五个参数指定任何引用的 URL。该参数通常为 WINHTTP_NO_REFERRER,表示无引用。倒数第二个参数指定您作为客户端将接受的媒体类型。如果该参数为 WINHTTP_DEFAULT_ACCEPT_TYPES,则不指定任何类型。通常,服务器利用此参数来表示客户端只接受文本响应。最后一个参数指定可以用来控制请求行为的标记。例如,您可能希望指定 WINHTTP_FLAG_SECURE 标记用来发出 SSL 请求。
接下来介绍同步编程模型和异步编程模型的不同之处。同步模型先调用一个函数来发送请求,然后在接收到响应后再调用一个函数,而异步模型使用回调函数,允许异步执行其余函数调用,因而并不会阻止调用线程。
使用 WinHttpSetStatusCallback 函数可将回调函数与请求对象相关联。
if (WINHTTP_INVALID_STATUS_CALLBACK == ::WinHttpSetStatusCallback(request,
Callback, WINHTTP_CALLBACK_FLAG_ALL_NOTIFICATIONS,
0)) // reserved
{
// Call GetLastError for error information
}
第一个 WinHttpSetStatusCallback 参数指定关于您希望通知其进程的请求。第二个参数指定回调函数的地址。第三个参数指定您希望收到的通知。WINHTTP_CALLBACK_FLAG_ALL_NOTIFICATIONS 只是一个包含所有可能通知的位掩码。
回调函数的原型应该如下所示:
void CALLBACK Callback(HINTERNET handle,
DWORD_PTR context,
DWORD code,
void* info,
DWORD length);
第一个参数提供回调与之相关的对象的句柄。从技术上讲,您可以使用回调处理会话对象和连接对象的通知,但这通常用于请求对象。第二个参数是应用程序定义的值,该值与发送时的请求相关联。第三个参数提供表示回调原因的代码。最后两个参数指定一个缓冲区,根据前一个参数提供的代码,该缓冲区可能包含其他信息。例如,如果接收到 WINHTTP_CALLBACK_STATUS_REQUEST_ERROR 通知代码,则该信息参数将指向 WINHTTP_ASYNC_RESULT 结构。
使用 WinHttpSendRequest 函数发送请求:
if (!::WinHttpSendRequest(request,
WINHTTP_NO_ADDITIONAL_HEADERS,
0, // headers length
WINHTTP_NO_REQUEST_DATA,
0, // request data length
0, // total length
0)) // context
{
// Call GetLastError for error information
}
第一个参数确定要发送的请求。第二个参数和第三个参数指定一个字符串,其中可能包含任何其他标头以包含该请求。如果指定 WINHTTP_NO_ADDITIONAL_HEADERS,则最初不会包含任何其他标头。也可以使用 WinHttpAddRequestHeaders 函数在发送请求之前向其中添加标头。第四个参数和第五个参数指定可选缓冲区,其中包含用作请求的内容或正文的所有数据。这通常用于 POST 请求。倒数第二个参数指定请求内容的总长度。此值用于创建此请求中包含的 Content-Length 标头。如果此值大于前面参数提供的数据的长度,则使用 WinHttpWriteData 函数编写其他数据来完成该请求。
因为我使用的是异步编程模型,所以函数无需等待发送请求就可以返回,而且 WinHTTP 可以通过回调函数返回 WinHttpSendRequest 的最后一个参数中指定的上下文值来提供状态更新。
现在暂停一下,想想如何使用 C++ 将到目前为止所介绍的内容联系在一起。在前几部分中,我介绍了相当简单的 WinHttpSession 类和 WinHttpConnection 类,用于建立会话和连接模型。我需要的 WinHttpRequest 类不仅要包含与请求相关的各种函数,而且还要能够为编写应用程序的特定类型的请求提供了一种简单方法。原因是该类可有效地管理缓冲区,该缓冲区可以在数据传输过程中存储请求数据和响应数据。
图 5 简要显示了我将在本文的其余部分中构建的 WinHttpRequest 类模板。OnCallback 成员函数的内部存在着许多有趣的逻辑。不过,我首先要介绍如何设计 WinHttpRequest 类模板。
图 5 WinHttpRequest 类模板
template
class WinHttpRequest : public WinHttpHandle
{
public:
HRESULT Initialize(PCWSTR path,
__in_opt PCWSTR verb,
const WinHttpConnection& connection)
{
HR(m_buffer.Initialize(8 * 1024));
// Call WinHttpOpenRequest and WinHttpSetStatusCallback.
}
HRESULT SendRequest(__in_opt PCWSTR headers,
DWORD headersLength,
__in_opt const void* optional,
DWORD optionalLength,
DWORD totalLength)
{
T* pT = static_cast(this);
// Call WinHttpSendRequest with pT as the context value.
}
protected:
static void CALLBACK Callback(HINTERNET handle,
DWORD_PTR context,
DWORD code,
void* info,
DWORD length)
{
if (0 != context)
{
T* pT = reinterpret_cast(context);
HRESULT result = pT->OnCallback(code,
info,
length);
if (FAILED(result))
{
pT->OnResponseComplete(result);
}
}
}
HRESULT OnCallback(DWORD code,
const void* info,
DWORD length)
{
// Handle notifications here.
}
SimpleBuffer m_buffer;
};
图 6 列出了 DownloadFileRequest 类。该类从 WinHttpRequest 派生并将其自身用作模板参数。Visual C++
® 库使用这一常用技术来实现有效编译时虚拟函数调用。类似于 WinHttpSession 类和 WinHttpConnection 类,WinHttpRequest 可提供用于初始化 WinHTTP 请求对象的 Initialize 成员函数。若要初始化 WinHTTP 请求对象,首先要创建请求数据和响应数据所要使用的缓冲区。该缓冲区的实现并不重要。它只需管理 BYTE 数组的生存期。接着,Initialize 调用 WinHttpOpenRequest 来创建 WinHTTP 请求对象,并调用 WinHttpSetStatusCallback 来将其与 WinHttpRequest 类内部的静态 Callback 成员函数关联起来。
SendRequest 成员函数只包含 WinHttpSendRequest 函数,并且将其“this”指针作为请求的上下文值进行传递。请注意,此值将传递到回调函数中,并可以确定通知所针对的请求对象。SendRequest 使用 static_cast 将“this”指针调整为指向由模板参数指定的派生类。这就是在这种情况下编译时多形性的实现过程。
最后,静态 Callback 成员函数将上下文值转换回指向请求对象的指针,并调用 OnCallback 成员函数来处理通知。既然网络错误是不可避免的,那么自然需要一些相应的处理方法。派生类可以实现采用 HRESULT 的 OnResponseComplete 成员函数。如果结果为 S_OK,则表示请求成功完成。但是如果回调中出现错误,则相应的 HRESULT 会指示请求已失败。
在介绍 OnCallback 成员函数的实现过程之前,让我们首先看一下
图 6 中所示的 DownloadFileRequest 具体类以全面了解 WinHttpRequest。这样做的好处是 DownloadFileRequest 可以重点了解下载文件涉及的细节,而不会陷入 HTTP 请求管理的复杂性问题中。其 Initialize 成员函数可以接受要下载的源文件的路径以及要将该文件编写到的目标流。每次接收到响应块时都会调用 OnReadComplete 成员函数;请求完成时则调用 OnResponseComplete。
图 6 DownloadFileRequest 类
class DownloadFileRequest : public WinHttpRequest
{
public:
HRESULT Initialize(PCWSTR source,
IStream* destination,
const WinHttpConnection& connection)
{
m_destination = destination;
return __super::Initialize(source,
0, // GET
connection);
}
HRESULT OnReadComplete(const void* buffer,
DWORD bytesRead)
{
return m_destination->Write(buffer,
bytesRead,
0); // ignored
}
void OnResponseComplete(HRESULT result)
{
if (S_OK == result)
{
// Download succeeded
}
}
private:
CComPtr m_destination;
};
请求通知
现在,我要讨论如何实现 OnCallback 成员函数。在
图 7 中您可以看到,OnCallback 再次使用 static_cast 来将“this”指针调整为指向派生类。然后,使用 switch 语句来处理各种通知。如果存在尚未处理的特定通知,则返回 S_FALSE。如果您希望覆盖派生类中的 OnCallback 成员函数,这可能会派上用场。
图 7 OnCallback 实现
HRESULT OnCallback(DWORD code,
const void* info,
DWORD length)
{
T* pT = static_cast(this);
switch (code)
{
case :
{
// Handle specific notification here.
break;
}
default:
{
return S_FALSE;
}
}
return S_OK;
}
在服务器可以访问的情况下,发送请求时第一个要注意的通知是 WINHTTP_CALLBACK_STATUS_SENDREQUEST_COMPLETE。这表示 WinHttpSendRequest 函数调用已成功完成。假设您正在发送一个简单的 GET 请求,则可以立即调用 WinHttpReceiveResponse 函数来指示 WinHTTP 开始读取响应:
case WINHTTP_CALLBACK_STATUS_SENDREQUEST_COMPLETE:
{
if (!::WinHttpReceiveResponse(m_handle,
0)) // reserved
{
return HRESULT_FROM_WIN32(::GetLastError());
}
break;
}
:
如果接收到响应,则会收到 WINHTTP_CALLBACK_STATUS_HEADERS_AVAILABLE 通知,指示可以读取响应标头。您可以查询可能与应用程序相关的各种标头。至少,您应该查询服务器返回的 HTTP 状态代码。可以使用 WinHttpQueryHeaders 函数检索标头信息。简单的实现可能只检查 HTTP_STATUS_OK 状态代码,指示请求成功(请参见
图 8)。
图 8 检查成功请求
case WINHTTP_CALLBACK_STATUS_HEADERS_AVAILABLE:
{
DWORD statusCode = 0;
DWORD statusCodeSize = sizeof(DWORD);
if (!::WinHttpQueryHeaders(m_handle,
WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER,
WINHTTP_HEADER_NAME_BY_INDEX,
&statusCode,
&statusCodeSize,
WINHTTP_NO_HEADER_INDEX))
{
return HRESULT_FROM_WIN32(::GetLastError());
}
if (HTTP_STATUS_OK != statusCode)
{
return E_FAIL;
}
if (!::WinHttpReadData(m_handle,
m_buffer.GetData(),
m_buffer.GetCount(),
0)) // async result
{
return HRESULT_FROM_WIN32(::GetLastError());
}
break;
}
WinHttpQueryHeaders 函数功能非常强大。它提供了一组在大多数情况下可以映射到标头的信息标记,而不是仅仅返回特定命名的标头的文本值。这些标记还提供了 HTTP 请求/响应状态的其他方面的访问权限。
在
图 8 中,为了检索服务器返回的状态代码,我使用了 WINHTTP_QUERY_STATUS_CODE 标记,还使用了 WINHTTP_QUERY_FLAG_NUMBER 修饰符,以告知 WinHttpQueryHeaders 我希望返回的值是 32 位数字而不是字符串。
第三个参数指定要查询的标头的名称。如果此参数为 WINHTTP_HEADER_NAME_BY_INDEX,则传递给上一个参数的标记便可以识别要返回的信息。接下来的两个参数指定接收该信息的缓冲区。最后一个参数用于读取名称相同的多个标头。如果此参数为 WINHTTP_NO_HEADER_INDEX,则只返回第一个标头的值。
如果对成功完成的请求满意,则可以调用 WinHttpReadData 函数开始读取响应。第二个参数和第三个参数指定接收数据的缓冲区。在这种特殊情况下,我会使用 WinHttpRequest 类模板提供的缓冲区。使用异步编程模型时,最后一个参数必须为零。
接收到足够的响应数据后,会收到 WINHTTP_CALLBACK_STATUS_READ_COMPLETE 通知,指示数据现已存储到了缓冲区中。读取的数据量由 OnCallback 的长度参数表示。该值可以为零,表示没有其他数据可用,也可以是任意一个小于 WinHttpReadData 函数调用中指定的缓冲区大小的值(请参见
图 9)。
图 9 检查通知
case WINHTTP_CALLBACK_STATUS_READ_COMPLETE:
{
if (0 < length)
{
HR(pT->OnReadComplete(m_buffer.GetData(),
length));
if (!::WinHttpReadData(m_handle,
m_buffer.GetData(),
m_buffer.GetCount(),
0)) // async result
{
return HRESULT_FROM_WIN32(::GetLastError());
}
}
else
{
pT->OnResponseComplete(S_OK);
}
break;
}
现在,您应该能够清楚地看到
图 6 中 DownloadFileRequest 类中的“事件”的来源。每次接收到数据时,WINHTTP_CALLBACK_STATUS_READ_COMPLETE 处理程序都会调用派生类中的 OnReadComplete 方法。然后,调用 WinHttpReadData 来读取下一个数据块。最后,没有其他数据可用时,将调用派生类中的 OnResponseComplete 方法,如果出现 S_OK,则表示已成功读取响应。
请求取消
操作的完成情况会始终通过回调函数通知给应用程序,因此,WinHTTP 提供的异步完成模型相对比较容易出错。但由于工作线程用于执行回调函数,取消请求时确实需要注意一些细节。
只需使用 WinHttpCloseHandle 函数关闭请求句柄便可取消请求,但关闭请求句柄后,您需要准备即将进行的后续回调。您可以调用 WinHttpSetStatusCallback 函数来指示在关闭请求句柄之前不再进行任何回调,但 WinHTTP 不会使此设置与其工作线程保持同步。
因此,您应确保适当保证所有共享状态的安全。特别是,必须在 WINHTTP_STATUS_CALLBACK_HANDLE_CLOSING 通知到达之后才能释放正与该回调共享的所有不受保护的数据。
此外,还需要使对 WinHttpCloseHandle 的调用与其他函数保持同步,这些函数可能正在处理请求对象,如 WinHttpReadData、WinHttpWriteData 和 WinHttpSendRequest。请求取消是异步 WinHTTP 中问题较多的领域之一,所以如果您的代码与取消有关,请务必要仔细检查。
发送请求数据
什么是 POST 请求?除了使用其他 HTTP 谓词外,这类请求还包含作为请求主体中的内容的其他数据。
图 10 介绍了更新后的 WINHTTP_CALLBACK_STATUS_SENDREQUEST_COMPLETE 处理程序,它可以轻松处理 GET 和 POST 这两个请求。
图 10 处理 GET 和 POST 请求
case WINHTTP_CALLBACK_STATUS_SENDREQUEST_COMPLETE:
case WINHTTP_CALLBACK_STATUS_WRITE_COMPLETE:
{
HRESULT result = pT->OnWriteData();
if (FAILED(result))
{
return result;
}
if (S_FALSE == result)
{
if (!::WinHttpReceiveResponse(m_handle,
0)) // reserved
{
return HRESULT_FROM_WIN32(::GetLastError());
}
}
break;
}
当 WINHTTP_CALLBACK_STATUS_SENDREQUEST_COMPLETE 通知到达时,它赋予派生类一个机会,允许其通过调用附带的 OnWriteData 成员函数将数据写入为请求的一部分。WinHttpRequest 类模板为仅返回 S_FALSE 的 GET 请求提供一个默认实现。可以使用 WinHttpWriteData 函数写入数据:
if (!::WinHttpWriteData(request,
buffer,
count,
0)) // async result
{
// Call GetLastError for error information
}
第二个参数和第三个参数指定包含要写入数据的缓冲区。写入数据后,会立即收到 WINHTTP_CALLBACK_STATUS_WRITE_COMPLETE。然后,再次调用派生类中的 OnWriteData 成员函数以写入下一个数据块。写入完成后立即返回 S_FALSE,而且基类处理程序继续调用 WinHttpReceiveResponse,正常接收响应。
其他功能
当然,有关如何使用 C++ 编写 HTTP 客户端应用程序的内容远不止这些,但很遗憾这里篇幅有限。但是,我希望本文中的详细讲解为您入门提供了足够的信息。
您可以看到,WinHTTP 为编写 HTTP 客户端应用程序提供了一个先进且功能强大的 API。除了我在本文前面介绍的所有功能外,WinHTTP 还提供了其他一些重要的功能,包括支持各种身份验证方案以及提供和验证 SSL 证书。此外,它还提供了构建和分析 URL 的功能。如果您正在寻找一个内容丰富的平台以构建高效且可缩放的 HTTP 客户端应用程序,WinHTTP 可能正是您所需要的。