在Java中做DNS查询,大多数人第一反应是InetAddress.getByName()。这个API确实方便,但它是一个黑盒:你无法指定用哪个DNS服务器、无法查询MX、TXT、NS等记录类型、拿不到TTL值,更无法控制超时和重试策略。如果要做网络诊断工具、域名监控系统或者自建DNS调度服务,就必须自己实现DNS客户端。本文基于RFC 1035规范,用纯Java实现一个支持UDP传输、可查询多种记录类型的DNS客户端库,并详细讲解报文格式与解析技巧。

一、DNS报文结构详解:实现之前必须搞懂的东西
DNS通信本质上是交换固定格式的二进制报文。一条DNS报文由五个部分组成:Header(头部)、Question(查询问题区)、Answer、Authority、Additional(后三者统称资源记录区)。头部固定12字节,结构如下:
| 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 | +---------------------+------------+ | ID (16bit) | Flags | +----------+----------+------------+ | QDCOUNT | ANCOUNT | NSCOUNT | ARCOUNT +----------+----------+------------+
其中ID是16位事务标识,客户端生成随机ID,响应报文中的ID必须与之匹配,否则报文应丢弃。Flags字段16位,从高到低依次是:QR(0表示查询,1表示响应)、Opcode(通常为0标准查询)、AA、TC、RD(期望递归,客户端一般置1)、RA、Z、RCODE(响应码,0表示成功,3表示域名不存在)。
Question区由三部分组成:QNAME(按长度前缀编码的域名)、QTYPE(记录类型,如1表示A记录、28表示AAAA记录、15表示MX、16表示TXT)、QCLASS(通常为1表示IN互联网类)。域名的编码方式是每个标签前放一个长度字节,例如www.ippipp.com编码为3www7example3com0,结尾的0表示根标签。
资源记录区每条记录包含NAME、TYPE、CLASS、TTL(32位生存时间秒数)、RDLENGTH(数据长度)和RDATA(具体数据)。A记录的RDATA是4字节IPv4地址,AAAA记录是16字节IPv6地址。理解了这些格式,后面的编解码就水到渠成了。
二、构建查询报文与发送UDP请求
构建报文时建议使用ByteArrayOutputStream配合DataOutputStream,逐字段写入。头部写入ID、Flags(查询报文Flags一般写0x0100,即RD位置1)、四个计数器(QDCOUNT为1,其余为0)。然后写Question区:先遍历域名的每个标签写入长度和内容,最后补一个0字节,再写入QTYPE和QCLASS各两个字节。
发送使用DatagramSocket和DatagramPacket。DNS的UDP端口是53,公网公共DNS如8.8.8.8(Google)或114.114.114.114(国内)都可以直接测试。需要注意UDP报文超过512字节时会被截断(TC位置1),此时应改用TCP重发。查询报文一般很小,不会超限,但解析响应时要检查TC位,保证健壮性。
下面的代码完成了报文构建与发送:
import java.io.*;
import java.net.*;
import java.util.Random;
public class DnsClient {
private final String dnsServer;
private final int timeoutMillis;
public DnsClient(String dnsServer, int timeoutMillis) {
this.dnsServer = dnsServer;
this.timeoutMillis = timeoutMillis;
}
public byte[] buildQuery(String domain, int qtype) throws IOException {
ByteArrayOutputStream buf = new ByteArrayOutputStream();
DataOutputStream out = new DataOutputStream(buf);
// 头部:随机ID,Flags=0x0100 表示期望递归
int id = new Random().nextInt(65536);
out.writeShort(id);
out.writeShort(0x0100);
out.writeShort(1); // QDCOUNT
out.writeShort(0); // ANCOUNT
out.writeShort(0); // NSCOUNT
out.writeShort(0); // ARCOUNT
// QNAME:每个标签前写长度字节
for (String label : domain.split("\\.")) {
out.writeByte(label.length());
out.writeBytes(label);
}
out.writeByte(0); // 根标签
out.writeShort(qtype); // 查询类型,1=A记录
out.writeShort(1); // QCLASS=IN
return buf.toByteArray();
}
public byte[] sendQuery(byte[] query) throws IOException {
try (DatagramSocket socket = new DatagramSocket()) {
socket.setSoTimeout(timeoutMillis);
InetAddress address = InetAddress.getByName(dnsServer);
DatagramPacket req = new DatagramPacket(query, query.length, address, 53);
socket.send(req);
byte[] buffer = new byte[1024];
DatagramPacket resp = new DatagramPacket(buffer, buffer.length);
socket.receive(resp);
// 校验响应ID与请求ID一致
int respId = ((buffer[0] & 0xFF) << 8) | (buffer[1] & 0xFF);
int reqId = ((query[0] & 0xFF) << 8) | (query[1] & 0xFF);
if (respId != reqId) {
throw new IOException("响应ID不匹配,丢弃该报文");
}
return buffer;
}
}
}这段代码有几个细节值得注意:ID校验可以防止端口上收到无关报文干扰;超时通过setSoTimeout设置,超时会抛出SocketTimeoutException,上层可以捕获后重试。生产环境中建议至少重试两次,并可以在多个DNS服务器之间做故障转移。
三、解析响应报文与域名压缩指针处理
解析是整个实现中最容易出错的部分,难点在于DNS报文的域名压缩机制。为了节省空间,报文中的域名可能出现压缩指针:当长度字节的高两位为11(即该字节值大于等于0xC0)时,表示后续14位是一个偏移量,指向报文中域名的实际位置。解析时需要从指针处跳转读取,读完一个指针指向的完整域名后回到原位置继续。可能出现多级指针嵌套,需要实现为循环或递归。
解析流程:先跳过头部12字节,解析Question区(通常原样跳过即可,但要按域名编码规则正确推进偏移量,因为域名可能有压缩指针)。然后循环解析ANCOUNT条资源记录:读NAME(可能是指针)、TYPE、CLASS、TTL、RDLENGTH,再根据TYPE解析RDATA。A记录直接读4个字节拼成IPv4地址,AAAA记录读16字节,CNAME记录读出一个域名并递归跟进查询。
下面是核心的解析实现:
import java.io.*;
public class DnsResponseParser {
private final byte[] data;
public DnsResponseParser(byte[] data) {
this.data = data;
}
// 读取域名,支持压缩指针
private String readName(DataInputStream in) throws IOException {
StringBuilder sb = new StringBuilder();
int jumps = 0;
while (true) {
int len = in.readUnsignedByte();
if (len == 0) break; // 根标签,结束
if ((len & 0xC0) == 0xC0) { // 压缩指针
int ptr = ((len & 0x3F) << 8) | in.readUnsignedByte();
if (++jumps > 10) throw new IOException("指针嵌套过深,疑似恶意报文");
// 用带偏移的流读取指针位置,读完不回写主流程
DataInputStream sub = new DataInputStream(
new ByteArrayInputStream(data, ptr, data.length - ptr));
if (sb.length() > 0) sb.append('.');
sb.append(readName(sub));
return sb.toString();
}
if (sb.length() > 0) sb.append('.');
byte[] label = new byte[len];
in.readFully(label);
sb.append(new String(label, "ASCII"));
}
return sb.toString();
}
public void parse() throws IOException {
DataInputStream in = new DataInputStream(new ByteArrayInputStream(data));
int id = in.readUnsignedShort();
int flags = in.readUnsignedShort();
int qdcount = in.readUnsignedShort();
int ancount = in.readUnsignedShort();
in.readUnsignedShort(); // NSCOUNT
in.readUnsignedShort(); // ARCOUNT
int rcode = flags & 0xF;
System.out.println("ID=" + id + " RCODE=" + rcode + " 记录数=" + ancount);
// 跳过Question区
for (int i = 0; i < qdcount; i++) {
readName(in);
in.readUnsignedShort(); // QTYPE
in.readUnsignedShort(); // QCLASS
}
// 解析Answer区
for (int i = 0; i < ancount; i++) {
String name = readName(in);
int type = in.readUnsignedShort();
in.readUnsignedShort(); // CLASS
long ttl = Integer.toUnsignedLong(in.readInt());
int rdlength = in.readUnsignedShort();
byte[] rdata = new byte[rdlength];
in.readFully(rdata);
if (type == 1 && rdlength == 4) { // A记录
String ip = String.format("%d.%d.%d.%d",
rdata[0] & 0xFF, rdata[1] & 0xFF,
rdata[2] & 0xFF, rdata[3] & 0xFF);
System.out.println(name + " -> " + ip + " TTL=" + ttl);
} else if (type == 5) { // CNAME记录
DataInputStream sub = new DataInputStream(new ByteArrayInputStream(rdata));
System.out.println(name + " CNAME -> " + readName(sub) + " TTL=" + ttl);
} else {
System.out.println(name + " type=" + type + " TTL=" + ttl);
}
}
}
}解析中要特别注意指针深度限制。恶意构造的报文可能造成指针循环,导致解析器无限递归栈溢出,上面代码用jumps计数限制最多跳转10次,这是安全实践的必备手段。另外readInt返回有符号整数,TTL要用Integer.toUnsignedLong转换才能正确显示大值。
四、串联调用与常见记录类型扩展
把前面的构建、发送、解析三步串联起来,一个最小可用的客户端就完成了。调用示例:
public class Main {
public static void main(String[] args) throws Exception {
DnsClient client = new DnsClient("8.8.8.8", 3000);
byte[] query = client.buildQuery("www.baidu.com", 1); // 1=A记录
byte[] response = client.sendQuery(query);
new DnsResponseParser(response).parse();
}
}运行后可以看到类似www.baidu.com -> 110.242.68.4 TTL=xxx的输出,说明整条链路已经跑通。扩展其他记录类型只需在解析器中增加分支:AAAA记录(type=28)读16字节按IPv6格式拼接,MX记录(type=15)前两个字节是优先级、后面是邮件服务器域名,TXT记录(type=16)RDATA本身又是一个长度前缀的字符串列表,NS记录(type=2)读一个域名即可。
如果要用到生产环境,还有几个改进方向:第一,实现TCP模式,当UDP响应TC位置1时自动降级为TCP重发(TCP模式要在报文前加2字节长度前缀);第二,增加并发缓存,用ConcurrentHashMap按域名和类型缓存结果并根据TTL设置过期时间;第三,支持EDNS0扩展,在Additional区添加OPT记录以扩大UDP报文上限到4096字节。此外Spring生态里也可以参考这个思路做响应式封装,配合Netty的异步UDP实现高吞吐查询网关。
自己实现一遍DNS客户端,收获的不只是一个工具类,而是对二进制协议解析、网络编程和RFC文档阅读能力的全面锻炼。这套方法同样适用于Redis协议、HTTP/2帧等其他二进制协议的解析实现,掌握了它,再面对类似的协议开发任务时会从容很多。
Java DNS客户端DNS协议解析UDP通信修改时间:2026-09-14 02:46:54