4. 自定义DNS解析器:实现Dns接口、替换默认解析逻辑、单元测试示例
默认的 DNS 解析,说白了就是调用系统 InetAddress.getAllByName()。这个方法在绝大多数场景下够用,但一旦你遇到网络环境复杂、或者需要统一管控域名解析结果的场景,它就有点力不从心了。
我前两年做一个海外 SDK 的时候,发现有些地区的运营商 DNS 会劫持,导致用户永远连不上正确的 IP。嗯,那时候我就意识到:必须自己掌控 DNS 解析。
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;
}
}
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");
}
}
InetAddress.getAllByName() 和你的 HTTPDNS 客户端。上面例子里我用了 Robolectric,因为它能模拟 Android 环境,但仍然建议你 mock 掉真正的 HTTP 调用。
另外,如果你用了缓存,一定要测一下过期时间和并发场景。我曾经在线上遇到一个问题:两个线程同时解析同一个域名,结果缓存里插入了两条记录,返回了重复的 IP。后来我用 Caffeine 的并发控制才解决。嗯,这里提醒一下。
- 实现
Dns接口时,优先用 HTTPDNS 或自定义 DNS 服务,系统 DNS 作为兜底。 - 缓存解析结果,过期时间建议 30~60 秒,不要过长。
- 单元测试使用 Mock 或 Shadow,不要真正发网络请求。
- 注意并发安全,使用
ConcurrentHashMap或Caffeine这类线程安全的缓存库。
4.4 自定义 DNS 解析器的整体流程
下面这张图帮你理清楚整个链路:
从图上你能看出来,整个流程其实就三步:查缓存 → 如果没有就走 HTTPDNS → 失败则回退系统 DNS。我个人觉得这个模式非常稳妥,既兼顾了速度(缓存),又保证了可靠性(兜底)。
resolveWithHttpDns() 中集成即可。不过要注意,第三方的接口可能会产生额外的费用。
好了,自定义 DNS 解析器这部分我就讲完了。你自己动手实现的时候,记得把缓存策略、超时控制、并发问题都考虑进去。下一个话题,我们会深入 OkHttp 的连接池优化,把网络连接的复用做到极致。不过那是下一次的事了。