Rust in Everything
基于 reqwest 0.13.5

DNS

主线位置:连接主线的第一步。连接器把目标 Uri 交给 hyper-util 的 HttpConnector;它发现主机名不是 IP 字面量,就调 reqwest 塞给它的 DynResolver,拿回一串 SocketAddr,再按 Happy Eyeballs 去连。

flowchart TB
  feat{"hickory_dns 开着?"} -- 否 --> gai["GaiResolver"]
  feat -- 是 --> hick["HickoryDnsResolver"]
  gai --> base["Arc dyn Resolve"]
  hick --> base
  custom["dns_resolver 自定义"] -. 替换 .-> base
  base --> ovr{"有 resolve 覆盖?"}
  ovr -- 是 --> wrap["DnsResolverWithOverrides"]
  ovr -- 否 --> dyn["DynResolver"]
  wrap --> dyn
  dyn --> http["HttpConnector"]
  dyn --> socks["SOCKS 本地解析"]
  http --> he["Happy Eyeballs 与 TCP"]

源码在 src/dns/,四个文件共 312 行:resolve.rs(199 行,trait、DynResolver、覆盖)、gai.rs(32 行)、hickory.rs(69 行)、mod.rs(12 行)。真正的解析都在别处:getaddrinfo 那条路是 hyper-util 的 GaiResolver,另一条是 hickory-resolver。Happy Eyeballs 和按地址的 TCP 超时也在 hyper-util 里,hyper 卷的 HttpConnector 讲过(同为 hyper-util 0.1.21),本页只讲 reqwest 加了什么,和这些机制在 reqwest 的配置下表现成什么样。

实验平台:M2 Max、macOS 27、rustc 1.99.0,reqwest 0.13.5,release 构建,所有客户端 .no_proxy()(原因见连接器)。卷里的实验模板打开了 hickory-dns feature,这一点下面会反复用到。实验不碰外网:只解析 localhost,其余名字全部用 resolve 覆盖或自定义 resolver 给出。

Resolve trait

/// Alias for an `Iterator` trait object over `SocketAddr`.
pub type Addrs = Box<dyn Iterator<Item = SocketAddr> + Send>;

/// Alias for the `Future` type returned by a DNS resolver.
pub type Resolving = Pin<Box<dyn Future<Output = Result<Addrs, BoxError>> + Send>>;

/// Trait for customizing DNS resolution in reqwest.
pub trait Resolve: Send + Sync {
    /// Performs DNS resolution on a `Name`.
    /// The return type is a future containing an iterator of `SocketAddr`.
    ///
    /// It differs from `tower_service::Service<Name>` in several ways:
    ///  * It is assumed that `resolve` will always be ready to poll.
    ///  * It does not need a mutable reference to `self`.
    ///  * Since trait objects cannot make use of associated types, it requires
    ///    wrapping the returned `Future` and its contained `Iterator` with `Box`.
    ///
    /// Explicitly specified port in the URL will override any port in the resolved `SocketAddr`s.
    /// Otherwise, port `0` will be replaced by the conventional port for the given scheme (e.g. 80 for http).
    fn resolve(&self, name: Name) -> Resolving;
}

hyper-util 的 resolver 是一个 Service<Name>(见 hyper 卷 HttpConnector 的「第二步:DNS」)。Resolve 是 reqwest 给用户的简化版,文档注释列了三处不同:没有 poll_ready,&self 而不是 &mut self,返回值全部装箱,所以能做成 Arc<dyn Resolve>。Name 包着 hyper-util 的 Name,只暴露 as_str()。

ClientBuilder::dns_resolver 接受任何 IntoResolve:实现了 Resolve 的 R、Arc<R>、Arc<dyn Resolve> 三种都行。这个 trait 是「不可命名」的(#[doc(hidden)] 的方法,只在 docs.rs 上导出),用户不能自己实现它。

选哪个 resolver

ClientBuilder::build 的开头:

        let resolver = {
            let mut resolver: Arc<dyn Resolve> = match config.hickory_dns {
                false => Arc::new(GaiResolver::new()),
                #[cfg(feature = "hickory-dns")]
                true => Arc::new(HickoryDnsResolver::default()),
                #[cfg(not(feature = "hickory-dns"))]
                true => unreachable!("hickory-dns shouldn't be enabled unless the feature is"),
            };
            if let Some(dns_resolver) = config.dns_resolver {
                resolver = dns_resolver;
            }
            if !config.dns_overrides.is_empty() {
                resolver = Arc::new(DnsResolverWithOverrides::new(
                    resolver,
                    config.dns_overrides,
                ));
            }
            DynResolver::new(resolver)
        };

三层,从里到外:

  1. 基础 resolver:config.hickory_dns 的默认值是 cfg!(feature = "hickory-dns")。feature 打开就是默认,不用调 hickory_dns(true)。cargo 的 feature 是叠加的,依赖树里任何一个 crate 打开了 reqwest 的 hickory-dns,你的 Client 就换了 resolver。no_hickory_dns() 在没有这个 feature 时也存在(是个空操作),文档注释写明了用途:「ensure a Client doesn't use the hickory-dns async resolver even if another dependency were to enable the optional hickory-dns feature」。
  2. dns_resolver(r) 整个替换掉第一层。
  3. 有 resolve / resolve_to_addrs 覆盖时,外面再包一层 DnsResolverWithOverrides,所以覆盖对自定义 resolver 也生效。

结果装进 DynResolver,克隆给 HttpConnector::new_with_resolver;开了 socks feature 时同一个 resolver 还交给连接器,用于 socks4:// / socks5://(本地解析的两种)把目标主机名先解析成 IP(见 代理)。

DynResolver:适配,顺便打标签

DynResolver 把 Arc<dyn Resolve> 适配回 hyper-util 要的 Service<Name>:

impl Service<HyperName> for DynResolver {
    type Response = Addrs;
    type Error = BoxError;
    type Future = Resolving;

    fn poll_ready(&mut self, _: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
        Poll::Ready(Ok(()))
    }

    fn call(&mut self, name: HyperName) -> Self::Future {
        let resolving = self.resolver.resolve(Name(name));
        // Tag resolution failures so `Error::is_dns` can recognize them once
        Box::pin(async move { resolving.await.map_err(crate::error::dns) })
    }
}

map_err(crate::error::dns) 把任何解析错误包进一个私有的 DnsError,Error::is_dns() 沿 source() 链找它。这是 0.13.5 新加的(#3057,「add Error::is_dns() to identify DNS resolution failures」)。之前 DNS 失败和「连接被拒」在 reqwest::Error 上都只是 is_connect(),要区分得自己去 source() 链里认 hyper-util 的错误文本。

一个总是失败的 resolver:

struct Fails;
impl Resolve for Fails {
  fn resolve(&self, name: Name) -> Resolving {
    let msg = format!("no such host: {}", name.as_str());
    Box::pin(async move { Err(msg.into()) })
  }
}
  failing resolver, other name: error after 0ms is_dns=true is_connect=true: no such host: b.test

is_dns() 和 is_connect() 同时为真:DNS 错误在 hyper-util 里被包成 ConnectError,再成为 client error (Connect)。

默认:getaddrinfo 在 tokio 的阻塞线程池

GaiResolver 只是包了 hyper-util 的同名类型:

impl Resolve for GaiResolver {
    fn resolve(&self, name: Name) -> Resolving {
        let mut this = self.0.clone();
        Box::pin(async move {
            this.call(name.0)
                .await
                .map(|addrs| Box::new(addrs) as Addrs)
                .map_err(|err| Box::new(err) as BoxError)
        })
    }
}

hyper-util 的 GaiResolver::call 用 tokio::task::spawn_blocking 调 to_socket_addrs(),也就是系统的 getaddrinfo。所以「默认 DNS 在哪个线程」的答案是:发请求的那个 tokio 运行时的阻塞线程池(阻塞线程池),不是 reqwest 自己的线程,也不是工作线程。

验证:用 current_thread 运行时(它没有工作线程,任何新线程都只能来自阻塞池),在 on_thread_start 里打印线程名,服务器同时监听 127.0.0.1 和 ::1 的同一端口,请求 http://localhost:<port>/:

  let rt = tokio::runtime::Builder::new_current_thread()
    .enable_all()
    .on_thread_start(|| println!("  [thread started: {}]", std::thread::current().name().unwrap_or("?")))
    .build()
    .unwrap();
  // ...
    get("hickory (default with the feature)", Client::builder(), &local).await;
    get("no_hickory_dns (getaddrinfo)", Client::builder().no_hickory_dns(), &local).await;
    get("no_hickory_dns, second client", Client::builder().no_hickory_dns(), &local).await;

连跑 3 次,线程那一行每次都只出现一次、位置相同:

== localhost, server on 127.0.0.1 and ::1
  hickory (default with the feature): 200 OK from [::1]:50252 after 5ms
  [thread started: tokio-rt-worker]
  no_hickory_dns (getaddrinfo): 200 OK from [::1]:50252 after 2ms
  no_hickory_dns, second client: 200 OK from [::1]:50252 after 0ms
  • 模板开着 hickory-dns,所以第一个 Client::builder() 用的是 hickory:没有起任何线程。
  • no_hickory_dns() 切回 getaddrinfo:起了一个阻塞线程。
  • 第二个 getaddrinfo 客户端没有再起线程:阻塞池的线程空闲后会保留一段时间(默认 10 秒)供复用。
  • 两条路都先连上 ::1:macOS 的 getaddrinfo 对 localhost 先给 IPv6(hyper 卷实测 [[::1]:0, 127.0.0.1:0]),hickory 的策略见下一节。

这也是 getaddrinfo 的代价所在:一次卡住的系统解析会一直占着一个阻塞线程,取消请求也停不下它(hyper 卷那一页和 tokio 卷的阻塞池都有实验)。

hickory-dns

HickoryDnsResolver 只有一个字段,Arc<OnceCell<TokioResolver>>。注释解释了为什么延迟构造:ClientBuilder::build 不一定在 tokio 运行时里调用,hickory 的 TokioResolver 却要。第一次 resolve 时才用 new_resolver 建:

fn new_resolver() -> Result<TokioResolver, NetError> {
    let mut builder = TokioResolver::builder_tokio().unwrap_or_else(|err| {
        log::debug!(
            "hickory-dns: failed to load system DNS configuration; falling back to Google DNS: {:?}",
            err
        );
        TokioResolver::builder_with_config(
            ResolverConfig::udp_and_tcp(&GOOGLE),
            TokioRuntimeProvider::default(),
        )
    });
    builder.options_mut().ip_strategy = LookupIpStrategy::Ipv6AndIpv4;
    builder.build()
}
  • builder_tokio() 读系统配置。注释说的是 /etc/resolv.conf,这只对 Linux 这类 Unix 成立;hickory-resolver 0.26.3 在 Apple 平台上编译的是 system_conf/apple.rs,从 SystemConfiguration 的 State:/Network/Global/DNS 取全局 DNS 服务器。读失败时退到 Google 的公共 DNS(ResolverConfig::udp_and_tcp(&GOOGLE)),只打一条 debug 日志。这个退路是 0.13.3 加的(#2797,「fallback to hickory_resolver's default config if reading /etc/resolv.conf fails」);之前读失败时 get_or_try_init 把错误原样返回,每次解析都失败。退路的另一面是:读不到系统配置时,查询会发往 Google 的服务器,而不是本机配置的那个。
  • hickory 自己发 UDP/TCP 查询,全程异步,不占阻塞线程,上面的实验里也没有起线程。localhost 不发查询:hickory-resolver 0.26.3 的 caching_client.rs 按 RFC 6761 在本地直接回答 loopback 地址。
  • hickory 只拿系统配置里的服务器地址,自己发查询,不经过系统的解析库。hickory_dns 的文档注释原话是「The hickory resolver does not work exactly the same, or on all the platforms that the default resolver does」。

ip_strategy = Ipv6AndIpv4:同时查 AAAA 和 A,IPv6 地址排前面。hyper-util 的 Happy Eyeballs 按列表第一个地址的协议族决定首选组(hyper 卷 HttpConnector 的「第三步」),所以这一行直接决定了先连 IPv6 还是 IPv4。

📝NOTE
冷知识:hickory 的地址顺序在 0.13.5 翻了个面

最早 reqwest 用 hickory 的默认策略 Ipv4ThenIpv6:只有查不到 IPv4 时才查 AAAA。#2378(「Enable happy eyeballs when using hickory-dns」,首次发布在 v0.12.6)的说明写道,这样「the address list hyper receives is almost always IPv4 only」,Happy Eyeballs 形同虚设,于是改成 Ipv4AndIpv6:两种都查,IPv4 在前。两年后 #3063(「use LookupIpStrategy::Ipv6AndIpv4 to prefer IPv6」,首次发布在 v0.13.5)只改了这一行,把 IPv6 挪到前面,CHANGELOG 记作「Fix hickory-dns feature to use Ipv6AndIpv4 strategy to prefer IPv6」。配合 hyper-util「第一个地址定首选族」的规则,开了 hickory-dns 的程序从 0.13.5 起对双栈主机先连 IPv6;上面的实验里,hickory 和本机 getaddrinfo 对 localhost 都先给出 ::1。

resolve 与 resolve_to_addrs

    pub fn resolve_to_addrs(mut self, domain: &str, addrs: &[SocketAddr]) -> ClientBuilder {
        self.config
            .dns_overrides
            .insert(domain.to_ascii_lowercase(), addrs.to_vec());
        self
    }

resolve 就是只给一个地址的 resolve_to_addrs。覆盖表在 build 时包成 DnsResolverWithOverrides:

impl Resolve for DnsResolverWithOverrides {
    fn resolve(&self, name: Name) -> Resolving {
        match self.overrides.get(name.as_str()) {
            Some(dest) => {
                let addrs: Addrs = Box::new(dest.clone().into_iter());
                Box::pin(std::future::ready(Ok(addrs)))
            }
            None => self.dns_resolver.resolve(name),
        }
    }
}
  • 精确匹配:一次 HashMap 查找,没有通配符,覆盖了 api.test 不会影响 www.api.test。键在插入时转小写,URL 的主机名经 url crate 解析后本来就是小写,所以大小写不影响。
  • 命中时根本不调下层 resolver,直接返回一个已完成的 future。
  • 覆盖只发生在 DNS 这一步。TLS 的 SNI 和证书校验用的仍是 URL 里的主机名,所以「把 api.test 指到本机的测试服务器,并按 api.test 校验它的证书」可以成立(TLS 配置的实验全是这样做的)。
  • IP 字面量的 URL 不经过 resolver,覆盖也就无从生效。

端口:覆盖地址回到 hyper-util 后,经过和普通解析结果相同的 set_port。URL 写了端口时总用 URL 的;URL 没写时,只有地址里的端口是 0 才换成 scheme 的默认端口(80 / 443)。服务器在 <port>,几种组合:

    let p = |x: u16| SocketAddr::from(([127, 0, 0, 1], x));
    get("override port 0, URL with port", Client::builder().resolve("a.test", p(0)), &format!("http://a.test:{port}/")).await;
    get("override with port, URL without port", Client::builder().resolve("a.test", p(port)), "http://a.test/").await;
    get("override port 1, URL with port", Client::builder().resolve("a.test", p(1)), &format!("http://a.test:{port}/")).await;
    get("override for A.TEST, URL a.test", Client::builder().resolve("A.TEST", p(port)), "http://a.test/").await;
    get("override + failing resolver", Client::builder().dns_resolver(Fails).resolve("a.test", p(port)), "http://a.test/").await;
    get("failing resolver, other name", Client::builder().dns_resolver(Fails).resolve("a.test", p(port)), "http://b.test/").await;
== resolve() overrides
  override port 0, URL with port: 200 OK from 127.0.0.1:50252 after 0ms
  override with port, URL without port: 200 OK from 127.0.0.1:50252 after 0ms
  override port 1, URL with port: 200 OK from 127.0.0.1:50252 after 0ms
  override for A.TEST, URL a.test: 200 OK from 127.0.0.1:50252 after 0ms
  override + failing resolver: 200 OK from 127.0.0.1:50252 after 0ms
  failing resolver, other name: error after 0ms is_dns=true is_connect=true: no such host: b.test

第二行:http://a.test/ 没写端口,却连到了 <port> 而不是 80,因为覆盖地址里的端口不是 0。第三行反过来,URL 的端口压过了覆盖里的 1。

📝NOTE
没有蠢问题

问: 测试里把一个假域名指到本机,用 resolve 好,还是写一个 dns_resolver 好?

答: 只是固定几个名字,用 resolve:它是一张查找表,命中时连下层 resolver 都不调,也就不会有一次真实的 DNS 查询漏出去。dns_resolver 换掉的是全部名字的解析,适合「所有查询都走公司内网 DNS」或「解析结果要做过滤」这类需求;而且 resolve 的覆盖仍然叠在它上面,两者可以一起用。

Happy Eyeballs 和 connect_timeout 的平分

reqwest 没有调 set_happy_eyeballs_timeout,hyper-util 的默认值 300ms 生效。connect_timeout 则如连接器所说用在两处,其中 hyper-util 那一处按一组里的地址数平分。resolve_to_addrs 正好能精确控制地址列表。下面的 127.0.0.2 在 macOS 上走 lo0 但没有分配给任何接口,SYN 发出去没有回应,连接会一直挂着(hyper 卷用过同一个办法):

    let black = SocketAddr::from(([127, 0, 0, 2], port));
    let live6: SocketAddr = format!("[::1]:{port}").parse().unwrap();
    let url = format!("http://dual.test:{port}/");
    get("[black4, live6]", Client::builder().resolve_to_addrs("dual.test", &[black, live6]), &url).await;
    get("[black4, live4], connect_timeout 1s", Client::builder().resolve_to_addrs("dual.test", &[black, p(port)]).connect_timeout(Duration::from_secs(1)), &url).await;
== resolve_to_addrs with a black-holed first address (127.0.0.2)
  [black4, live6]: 200 OK from [::1]:50252 after 302ms
  [black4, live4], connect_timeout 1s: 200 OK from 127.0.0.1:50252 after 503ms
  • [black4, live6]:第一个地址是 IPv4,首选组 [127.0.0.2],备用组 [::1]。首选挂住,300ms 后备用组开跑,立刻连上。没有设任何超时,也只多花了 300ms。
  • [black4, live4]:两个地址同一族,没有备用组,只能顺序试。connect_timeout 是 1 秒,hyper-util 给每个地址 500ms,第一个超时后才试第二个。没设 connect_timeout 的话,第一个地址要等操作系统的连接超时。

慢在 DNS 本身时,起作用的是外层那一处。一个要 2 秒才回答的 resolver(连接器一页的同一个实验程序):

== resolver that takes 2s
no timeouts: 200 OK after 2005ms
connect_timeout(300ms): after 303ms is_connect=true is_timeout=true is_dns=false
    error sending request for url (http://slow.test:64705/) <- client error (Connect) <- operation timed out

hyper-util 的 connect_timeout 只管 TCP,管不到解析;reqwest 包在整个连接器外面的超时在 300ms 处把它截断。这时 is_dns() 是假:错误是超时,不是 resolver 返回的错误。

❗IMPORTANT
动脑筋:resolve_to_addrs 给四个 IPv4 地址,前三个是黑洞 127.0.0.2,最后一个是活的 127.0.0.1,connect_timeout 设 1 秒。请求成功还是超时?大约多久?
    get("[black4, black4, black4, live4], connect_timeout 1s", Client::builder().resolve_to_addrs("dual.test", &[black, black, black, p(port)]).connect_timeout(Duration::from_secs(1)), &url).await;

动脑筋答案

  [black4, black4, black4, live4], connect_timeout 1s: 200 OK from 127.0.0.1:50252 after 760ms

连跑 3 次是 758ms 到 762ms。四个地址同属 IPv4,没有备用组;hyper-util 把 1 秒平分成每个地址 250ms,三个黑洞各耗 250ms,第四个立刻连上,总共约 750ms,赶在外层 1 秒超时之前。

同一个程序再加两行,黑洞增加到 5 个和 6 个:

  5 x black4 + live4, connect_timeout 1s: 200 OK from 127.0.0.1:56522 after 849ms
  6 x black4 + live4, connect_timeout 1s: 200 OK from 127.0.0.1:56522 after 876ms

N 个黑洞加一个活地址,耗时约 N/(N+1) 秒,总在 1 秒之内:平分保证了最后一个地址也有它那一份。平分的代价在另一头:地址越多,每个地址分到的时间越短,按代码推,一个能连上、但 TCP 握手要比这一份更久的地址会被判超时,转去试下一个。

小结

  • Resolve 是 hyper-util Service<Name> 的简化版:&self、无 poll_ready、全装箱;DynResolver 把它适配回去,并给错误打上 DnsError 标签,0.13.5 起 is_dns() 能认出 DNS 失败。
  • resolver 三层:getaddrinfo 或 hickory,dns_resolver 整体替换,resolve 覆盖叠在最外;hickory-dns feature 一打开就是默认,no_hickory_dns() 在任何 feature 组合下都能关掉它。
  • 默认的 getaddrinfo 跑在当前 tokio 运行时的阻塞线程池里(实测 current_thread 运行时上起了一个阻塞线程);hickory 全异步,不起线程,读不到系统配置时退到 Google DNS。
  • 覆盖是精确匹配、键转小写、命中时不调下层 resolver;端口规则:URL 有端口用 URL 的,否则地址端口为 0 才用 scheme 默认端口;SNI 和证书校验仍按 URL 主机名。
  • Happy Eyeballs 用 hyper-util 默认的 300ms,首选族由列表第一个地址决定,hickory 从 0.13.5 起 IPv6 在前;connect_timeout 在 hyper-util 里按地址平分,外层那一份还管 DNS 和 TLS。

💬 关联讨论

发起讨论
加载中...