4. 自定义DNS解析器:实现Dns接口、替换默认解析逻辑、单元测试示例

默认的 DNS 解析,说白了就是调用系统 InetAddress.getAllByName()。这个方法在绝大多数场景下够用,但一旦你遇到网络环境复杂、或者需要统一管控域名解析结果的场景,它就有点力不从心了。

我前两年做一个海外 SDK 的时候,发现有些地区的运营商 DNS 会劫持,导致用户永远连不上正确的 IP。嗯,那时候我就意识到:必须自己掌控 DNS 解析。

核心思路:实现 Retrofit 的 Dns 接口,把系统默认的解析逻辑替换成你自己的。这样你就能随心所欲地返回 IP 列表、缓存解析结果、甚至绕过恶意的 DNS 污染。

4.1 实现 Dns 接口

Retrofit 的 Dns 接口就一个方法:

public interface Dns {
    List<InetAddress> lookup(String hostname) throws UnknownHostException;
}

是不是很简单?你只需要返回一个 InetAddress 的列表就行。我习惯把所有自定义逻辑封装成一个类,比如 CustomDns:

public class CustomDns implements Dns {

    private final Cache<String, List<InetAddress>> dnsCache;
    
    public CustomDns() {
        this.dnsCache = Caffeine.newBuilder()
                .expireAfterWrite(30, TimeUnit.SECONDS)
                .maximumSize(200)
                .build();
    }

    @Override
    public List<InetAddress> lookup(String hostname) throws UnknownHostException {
        // 先从缓存找
        List<InetAddress> cached = dnsCache.getIfPresent(hostname);
        if (cached != null) {
            return cached;
        }

        // 自己的解析逻辑 —— 比如优先用 HTTPDNS 服务
        List<InetAddress> result = resolveWithHttpDns(hostname);
        if (result == null || result.isEmpty()) {
            // 回退到系统 DNS
            result = Dns.SYSTEM.lookup(hostname);
        }

        // 放入缓存
        dnsCache.put(hostname, result);
        return result;
    }

    private List<InetAddress> resolveWithHttpDns(String hostname) {
        // 这里可以发 HTTP 请求到自己的 DNS 服务端
        // 返回 IP 列表,示例略
        return null;
    }
}
避坑指南:我曾经犯过一个错误 —— 直接把 HTTPDNS 的超时设成 5 秒。用户网络差的时候,DNS 解析阻塞了 OkHttp 的连接池,整个 App 卡住。后来我把 HTTPDNS 的超时控制在 2 秒以内,同时加了一个「兜底策略」:如果 HTTPDNS 失败,马上走系统 DNS 兜底。

4.2 替换默认解析逻辑

实现好 Dns 接口之后,怎么让 Retrofit 用你的实现?很简单,在 OkHttpClient 上设置就行:

OkHttpClient client = new OkHttpClient.Builder()
        .dns(new CustomDns())
        .build();

Retrofit retrofit = new Retrofit.Builder()
        .baseUrl("https://api.example.com/")
        .client(client)
        .build();

看,就一行 .dns(new CustomDns())。一旦配置好,Retrofit 内部所有的域名解析都会走你的 CustomDns.lookup()。你想想看,整个应用的 DNS 解析行为都由你掌控,多爽。

但这里有个小坑:OkHttp 的 DNS 是全局的,你配置了 CustomDns,那同一个 OkHttpClient 发出的所有请求都会用这个解析器。如果某些 API 需要特殊的 DNS 策略,你可以创建多个 OkHttpClient 实例来隔离。

4.3 单元测试示例

好,代码写完了,怎么保证它不出幺蛾子?我个人的习惯是:DNS 解析器一定要写单元测试。因为它直接影响网络请求的第一步,出了问题很难排查。

这里我给出一个用 MockWebServer 模拟 DNS 响应的测试示例:

@RunWith(RobolectricTestRunner.class)
public class CustomDnsTest {

    private CustomDns customDns;

    @Before
    public void setUp() {
        customDns = new CustomDns();
    }

    @Test
    public void lookup_shouldReturnCachedResult_whenCalledTwice() throws Exception {
        // 第一次调用,触发 HTTPDNS
        List<InetAddress> first = customDns.lookup("example.com");
        assertNotNull(first);
        assertTrue(first.size() > 0);

        // 第二次调用,应该从缓存返回,不触发 HTTPDNS
        List<InetAddress> second = customDns.lookup("example.com");
        assertSame(first, second);  // 同一对象引用
    }

    @Test(expected = UnknownHostException.class)
    public void lookup_shouldThrow_whenHostNotResolved() throws Exception {
        // 模拟一个不可能解析的域名
        customDns.lookup("nonexistent.invalid");
    }
}
警告:不要在单元测试里真的访问网络!否则网络波动会导致测试不稳定。正确做法是用 Mock 或者 Robolectric 的 Shadow 来模拟 InetAddress.getAllByName() 和你的 HTTPDNS 客户端。上面例子里我用了 Robolectric,因为它能模拟 Android 环境,但仍然建议你 mock 掉真正的 HTTP 调用。

另外,如果你用了缓存,一定要测一下过期时间和并发场景。我曾经在线上遇到一个问题:两个线程同时解析同一个域名,结果缓存里插入了两条记录,返回了重复的 IP。后来我用 Caffeine 的并发控制才解决。嗯,这里提醒一下。

最佳实践总结:
  • 实现 Dns 接口时,优先用 HTTPDNS 或自定义 DNS 服务,系统 DNS 作为兜底。
  • 缓存解析结果,过期时间建议 30~60 秒,不要过长。
  • 单元测试使用 Mock 或 Shadow,不要真正发网络请求。
  • 注意并发安全,使用 ConcurrentHashMap 或 Caffeine 这类线程安全的缓存库。

4.4 自定义 DNS 解析器的整体流程

下面这张图帮你理清楚整个链路:

自定义 DNS 解析器核心流程 App 发起网络请求 OkHttp 调用 Dns.lookup() CustomDns.lookup(hostname) 缓存命中? 是 返回缓存 IP 列表 否 HTTPDNS 解析 成功? 是 存入缓存 & 返回 否 系统 DNS 兜底解析

从图上你能看出来,整个流程其实就三步:查缓存 → 如果没有就走 HTTPDNS → 失败则回退系统 DNS。我个人觉得这个模式非常稳妥,既兼顾了速度(缓存),又保证了可靠性(兜底)。

小贴士:如果你不想自己搭建 HTTPDNS 服务,也可以直接用第三方服务,比如阿里云 HTTPDNS、腾讯云 DNSPod 等。它们提供 SDK 或普通 HTTP 接口,你只需要在 resolveWithHttpDns() 中集成即可。不过要注意,第三方的接口可能会产生额外的费用。

好了,自定义 DNS 解析器这部分我就讲完了。你自己动手实现的时候,记得把缓存策略、超时控制、并发问题都考虑进去。下一个话题,我们会深入 OkHttp 的连接池优化,把网络连接的复用做到极致。不过那是下一次的事了。