Windows-classic-samples 中的 DNSAsyncQuery 示例:用 DnsQueryEx 实现异步 DNS 查询与结果解析

原创2026-10-14 15:00:581,698 阅读
文章标签:示例工程

Windows-classic-samples 中的 DNSAsyncQuery 示例:用 DnsQueryEx 实现异步 DNS 查询与结果解析

本指南围绕 Windows-classic-samples 仓库中的 DNSAsyncQuery 示例展开,完整讲解如何用 Win32 的 DnsQueryEx API 以异步方式发起 DNS 查询、设置超时与取消机制、通过完成回调解析 A/AAAA/SOA/SRV 等各类资源记录,并支持指定特定 DNS 服务器。读完本文后,你能掌握 DNS_QUERY_REQUEST 的完整字段用法、异步上下文生命周期管理(引用计数 + 事件同步)的编程模型,以及命令行查询工具(DnsQuery -q <name> [-t type] [-o options] [-s server])的全部参数细节,可直接将该套异步 DNS 查询模式移植到自己的 Windows 桌面程序中。

示例概述与运行环境

该示例的定位是"演示如何异步查询 DNS 记录并解析查询结果"(原文见 Samples/DNSAsyncQuery/README.md)。它由三个源文件构成一个控制台命令行工具,编译产物名为 DnsQuery.exe(尽管主源文件叫 DnsQueryEx.cpp):

文件 职责
DnsQueryEx.cpp 主程序:组装异步查询请求、引用计数管理、超时取消、完成回调
ParseArgs.cpp 命令行参数解析:-q / -t / -o / -s 及帮助输出
PrintDnsRecord.cpp 按记录类型分发并打印各类 DNS 资源记录内容

环境要求(来自 README):

  • 客户端:Windows 8.1;服务器:Windows Server 2012 R2。文档同时说明,虽然示例最初基于 Visual Studio 2013 针对 Windows 8.1 / Server 2012 R2 创建,但在多数情况下可在更新的版本上不做修改直接运行。
  • 从工程文件 DnsQueryEx.vcxproj 看,工程使用 v110 平台工具集(对应 VS2013)、Unicode 字符集、Level4 警告级别且 TreatWarningAsError 开启;链接依赖包含 dnsapi.lib(DnsQueryEx 所在库)、ws2_32.lib(WSAStringToAddress 用于解析服务器 IP)、ntdll.lib(RtlIpv4AddressToString / RtlIpv6AddressToString 用于地址字符串化),子系统为 Console。

命令行接口:参数、查询类型与查询选项

工具的使用形式定义在 ParseArgs.cpp 的 PrintHelp() 中:

Usage: DnsQuery -q <QueryName> [-t QueryType] [-s DnsServerIP] [-o QueryOptions]
<QueryName>    Input query Name
<QueryType>    Input query type: A, PTR, NS, AAAA, TXT....
<DnsServerIP>  DNS Server IP address
<QueryOptions> Input query flags (use combination of following numerics or
               one of the below option string):
               0x0      DNS_QUERY_STANDARD
               0x1      DNS_QUERY_ACCEPT_TRUNCATED_RESPONSE
               0x2      DNS_QUERY_USE_TCP_ONLY
               0x4      DNS_QUERY_NO_RECURSION
               0x8      DNS_QUERY_BYPASS_CACHE
               0x10     DNS_QUERY_NO_WIRE_QUERY
               0x20     DNS_QUERY_NO_LOCAL_NAME
               0x40     DNS_QUERY_NO_HOSTS_FILE
               0x100    DNS_QUERY_WIRE_ONLY
               0x400    DNS_QUERY_MULTICAST_ONLY
               0x800    DNS_QUERY_NO_MULTICAST
               0x1000   DNS_QUERY_TREAT_AS_FQDN
               0x200000 DNS_QUERY_DISABLE_IDN_ENCODING

几个值得注意的解析细节(对应 ParseArguments(),位于 ParseArgs.cpp#L182-L300):

  • 默认值:-t 缺省为 DNS_TYPE_A,-o 缺省为 0(即 DNS_QUERY_STANDARD),-q 为必填项,缺少时打印帮助并返回 ERROR_INVALID_PARAMETER。
  • 查询类型双模式解析:ExtractUserFriendlyQueryType()(ParseArgs.cpp#L109-L131)先把输入与一张包含 50 余种条目的符号表比对(ZERO、A、NS、CNAME、SOA、PTR、MX、TXT、AAAA、SRV、SIG、KEY、DS、RRSIG、NSEC、NSEC3、NSEC3PARAM 等,见 TypeTable),映射到对应的 DNS_TYPE_* 常量;匹配失败则回退为按数值解析(wcstoul(..., 0)),因此可以传十进制/十六进制记录类型号。
  • 查询选项双模式解析:ExtractUserFriendlyQueryOptions() 采用同样的策略,可传 DNS_QUERY_BYPASS_CACHE 这类符号名,也可传 0x8 这类位值,便于组合多个选项位。

异步查询核心流程:DnsQueryRequest、引用计数与超时取消

主程序 wmain()(DnsQueryEx.cpp#L84-L208)完整演示了一次异步 DNS 查询的生命周期,可以按以下五步理解:

第 1 步:分配带引用计数的查询上下文。 QUERY_CONTEXT 结构(DnsQueryEx.cpp#L28-L37)聚合了整次异步查询所需的全部状态:

typedef struct _QueryContextStruct
{
    ULONG               RefCount;
    WCHAR               QueryName[DNS_MAX_NAME_BUFFER_LENGTH];
    WORD                QueryType;
    ULONG               QueryOptions;
    DNS_QUERY_RESULT    QueryResults;
    DNS_QUERY_CANCEL    QueryCancelContext;
    HANDLE              QueryCompletedEvent;
}QUERY_CONTEXT, *PQUERY_CONTEXT;

AllocateQueryContext()(DnsQueryEx.cpp#L239-L271)从进程堆分配该结构(HEAP_ZERO_MEMORY),把 QueryResults.Version 初始化为 DNS_QUERY_RESULTS_VERSION1,并创建一个自动重置事件 QueryCompletedEvent 用于等待查询完成。引用计数的意义在于:DnsQueryEx 的文档要求 QueryResults 与 QueryCancelContext 指针在查询完成前必须保持有效(源码注释中明确写到 "QueryResults and QueryCancelContext should be valid till query completes"),因此主线程先持有一份引用,在发起查询前再 AddReferenceQueryContext() 追加一份(DnsQueryEx.cpp#L155-L163),交给完成回调在结束时释放;两份引用都归零后 DeReferenceQueryContext() 才会关闭事件句柄并释放内存,避免了回调与主线程的释放竞争。

第 2 步:填充 DNS_QUERY_REQUEST。 关键字段包括:

ZeroMemory(&DnsQueryRequest, sizeof(DnsQueryRequest));
DnsQueryRequest.Version = DNS_QUERY_REQUEST_VERSION1;
DnsQueryRequest.QueryName = QueryContext->QueryName;
DnsQueryRequest.QueryType = QueryContext->QueryType;
DnsQueryRequest.QueryOptions = (ULONG64)QueryContext->QueryOptions;
DnsQueryRequest.pQueryContext = QueryContext;
DnsQueryRequest.pQueryCompletionCallback = QueryCompleteCallback;

即版本标识为 DNS_QUERY_REQUEST_VERSION1,把解析好的查询名、类型、选项(QueryOptions 是 ULONG64,这里从 ULONG 提升)、用户上下文和完成回调一并挂入请求结构。

第 3 步:发起异步查询并区分两种返回。 调用 DnsQueryEx(&DnsQueryRequest, &QueryContext->QueryResults, &QueryContext->QueryCancelContext) 后(DnsQueryEx.cpp#L161-L175):

  • 返回 DNS_REQUEST_PENDING:查询被接受,真正在后台进行,之后系统会调用 QueryCompleteCallback;
  • 返回其他值(例如查询直接命中本地缓存而同步完成):完成例程不会被调用,示例代码此时手动调用一次 QueryCompleteCallback(QueryContext, &QueryContext->QueryResults) 走统一的完成路径。

第 4 步:超时等待与取消。 主线程在 QueryCompletedEvent 上等待,超时时间为 5 秒(QueryTimeout = 5 * 1000,DnsQueryEx.cpp#L92):

if (WaitForSingleObject(QueryContext->QueryCompletedEvent,
                        QueryTimeout)  == WAIT_TIMEOUT)
{
    wprintf(L"The query took longer than %d seconds to complete; "
            L"cancelling the query...\n", QueryTimeout/1000);
    DnsCancelQuery(&QueryContext->QueryCancelContext);
    WaitForSingleObject(QueryContext->QueryCompletedEvent, INFINITE);
}

这里体现了一个重要的 API 语义:DnsCancelQuery 只是一个"取消请求",它会加速流程结束,但并不保证回调立即返回——所以取消之后仍要以 INFINITE 继续等待完成回调(源码注释:"Cancel is just a request which will speed the process. It should still wait for the completion callback")。

第 5 步:完成回调统一收尾。 QueryCompleteCallback()(DnsQueryEx.cpp#L337-L363)无论成功失败都执行固定的收尾序列:

  1. 若 QueryResults->QueryStatus == ERROR_SUCCESS,打印成功信息并调用 PrintDnsRecordList(QueryResults->pQueryRecords) 输出全部记录;否则打印错误码;
  2. 若存在记录链表,用 DnsRecordListFree(QueryResults->pQueryRecords, DnsFreeRecordList) 释放记录及关联字符串;
  3. SetEvent(QueryContext->QueryCompletedEvent) 通知主线程;
  4. DeReferenceQueryContext(&QueryContext) 释放回调持有的那一份引用。

至此主线程 exit 标签处再 DeReferenceQueryContext 释放自己那份引用,整个上下文生命周期闭环。

DNS 结果解析:按类型分发的记录打印

PrintDnsRecord.cpp 展示了如何消费 DNS_QUERY_RESULT 中的记录链表。每条 PDNS_RECORD 都是链表节点(pNext),PrintRecord()(PrintDnsRecord.cpp#L281-L314)先打印通用头部字段——Owner(所有者域名)、Type、Flags(含 Section / Delete / CharSet 位域)、TTL、DataLength 等——再按 wType 索引到函数指针表 PrintDataTable 调类型专属打印函数(PrintDnsRecord.cpp#L225-L279)。

示例实现了以下类型的专属解析:

记录类型 打印函数 提取的字段
A ARecordPrint 把 Data.A.IpAddress 经 RtlIpv4AddressToString 转成点分十进制
AAAA AaaaRecordPrint Data.AAAA.Ip6Address 经 RtlIpv6AddressToString 转成 IPv6 字符串
PTR / NS / CNAME 等 PtrRecordPrint 统一取 Data.PTR.pNameHost(主机名字段)
SOA SoaRecordPrint 主服务器、管理员邮箱、序列号、Refresh / Retry / Expire / 默认 TTL
MX / AFSDB / RT MxRecordPrint wPreference(优先级)与 pNameExchange(交换/目标主机)
TXT / HINFO / X25 等 TxtRecordPrint 遍历 Data.TXT.pStringArray 数组逐条输出字符串
SRV SrvRecordPrint wPriority、wWeight、wPort、pNameTarget
SIG / RRSIG SigRecordPrint 覆盖类型、算法、标签数、原 TTL、签名起止时间、Key Tag、签名者名
KEY / DNSKEY KeyRecordPrint 标志位、协议、算法
DS DsRecordPrint Key Tag、算法、摘要类型
NSEC NsecRecordPrint 下一域名
NSEC3 / NSEC3PARAM Nsec3RecordPrint 等 哈希算法、标志、迭代次数

表外或无实现(表中为 NULL)的类型只打印通用头部,不会崩溃——PrintRecord 里有 DnsRecord->wType < ARRAYSIZE(PrintDataTable) 的越界保护(PrintDnsRecord.cpp#L309-L313)。PrintDnsRecordList() 沿 pNext 遍历整条链,逐条调用 PrintRecord 输出(PrintDnsRecord.cpp#L316-L326)。

指定 DNS 服务器:构造 DNS_ADDR_ARRAY

-s 参数用于把查询定向到指定的 DNS 服务器 IP,而非系统的默认解析器。实现位于 CreateDnsServerList()(DnsQueryEx.cpp#L277-L330):

  1. 先 WSAStartup(MAKEWORD(2, 2), ...) 初始化 Winsock;
  2. 用 WSAStringToAddress 把 IP 字符串解析进 SOCKADDR_STORAGE,先按 AF_INET(IPv4)尝试,失败再以 AF_INET6(IPv6)重试;
  3. 成功后把地址拷贝进 DNS_ADDR_ARRAY 的第一个槽位(MaxCount = 1、AddrCount = 1,拷贝长度为 DNS_ADDR_MAX_SOCKADDR_LENGTH),最后 WSACleanup()。

只有当用户显式指定了服务器(ServerIp<a href="https://link.gitcode.com/i/1e594920d6010950cf644b77afab7c62" target="_blank">0] != L'\0')时,才会把该数组指针挂到 DnsQueryRequest.pDnsServerList([DnsQueryEx.cpp#L142-L153);不指定时请求结构保持 ZeroMemory 后的默认状态,走系统默认 DNS 配置。

构建与运行

README 给出的操作步骤(对应 README 的 "Build the sample" / "Run the sample" 章节):

  1. 启动 Visual Studio,选择 File > Open > Project/Solution;
  2. 进入示例目录 Samples/DNSAsyncQuery/cpp,打开解决方案文件 DnsQueryEx.sln;
  3. 按 F7(Visual Studio 2013 中为 F6)或使用 Build > Build Solution 编译,产物输出到默认的 Debug/Release 目录,可执行文件名 DnsQuery.exe(由 DnsQueryEx.vcxproj 中 <TargetName>DnsQuery</TargetName> 决定);
  4. 调试运行按 F5 或 Debug > Start Debugging;不带调试运行按 Ctrl+F5 或 Debug > Start Without Debugging。

运行后即可用命令行发起查询,例如:

DnsQuery -q example.com                :: 默认 A 记录、标准选项
DnsQuery -q example.com -t AAAA        :: 按符号名查 IPv6 记录
DnsQuery -q example.com -t SOA         :: 查 SOA 记录
DnsQuery -q example.com -o DNS_QUERY_BYPASS_CACHE -s 8.8.8.8
                                       :: 绕过缓存,并定向到指定 DNS 服务器

关键要点小结

  • API 选择:同步场景用 DnsQuery,异步场景用 DnsQueryEx;两者共用同一套记录解析与释放逻辑(DnsRecordListFree + DnsFreeRecordList)。
  • 生命周期约束:DNS_QUERY_RESULT 和 DNS_QUERY_CANCEL 指针必须存活到完成回调返回,示例用引用计数的 QUERY_CONTEXT 保证这一点;跨线程传递查询状态时这是必须遵守的约束。
  • 双路径完成:DNS_REQUEST_PENDING 表示回调稍后触发;否则查询已同步完成,回调不会被调用,需要调用方自己补齐完成逻辑——这是很多异步 DNS 调用容易漏掉的分支。
  • 取消不是同步终止:DnsCancelQuery 发出后仍须等待完成回调,主流程不能直接释放上下文。
  • 服务器定向:通过 pDnsServerList 传入 DNS_ADDR_ARRAY 即可覆盖默认解析器,地址可用 WSAStringToAddress 从字符串构造,同时兼容 IPv4/IPv6。

相关 API 背景可参考 Windows SDK 头文件 <windns.h> 中的 DNS_QUERY_REQUEST / DNS_QUERY_RESULT / DNS_QUERY_CANCEL 定义;仓库内还有一个姊妹示例 DNSAsyncNetworkNameResolution,演示的是基于 Winsock GetAddrInfoEx 的异步主机名解析,与本示例的 DnsQueryEx 记录级查询形成互补。

登录后查看全文
Windows-classic-samples