导读:本期聚焦于行者创作的《如何用Java实现一个DNS客户端库?从协议解析到完整代码实现》,敬请观看详情。DNS协议是互联网最基础的解析服务,但JDK自带的InetAddress只能做简单查询,无法控制查询类型、读取TTL或指定DNS服务器。本文从RFC 1035协议规范入手,先讲清DNS报文的头部、Question区、Resource Record区的二进制结构,再动手用Java实现一个支持UDP传输的DNS客户端:涵盖报文构建、响应解析、域名压缩指针处理、A记录与AAAA记录识别、超时重传等核心环节,最后给出完整可运行的代码示例与测试方法。掌握这些内容后,你可以轻松实现自定义DNS解析器、域名探测工具或小型递归查询服务。

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

如何用Java实现一个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各两个字节。

发送使用DatagramSocketDatagramPacket。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

免责声明:已尽一切努力确保本网站所含信息的准确性。网站作品多为原创整理与精心创作,观点力求客观中立。本站旨在免费分享,内容仅供个人学习、研究或参考使用。若引用了第三方作品,版权归原作者所有。如内容涉及您的权益,请联系我们进行处理Email:chomcom@qq.com。
引用或转载本作品时,请注明当前出处:https://www.ipipp.com/html/20260914/56411.html,基于非商业用途的前提下,欢迎转载或二创本作品。
内容垂直聚焦
专注技术核心技术栏目,确保每篇文章深度聚焦于实用技能。从代码技巧到架构设计,为用户提供无干扰的纯技术知识沉淀,精准满足专业提升需求。
知识结构清晰
覆盖从开发到部署的全链路。AI、前端、编程、数据库、服务器、建站、系统层层递进,构建清晰学习路径,帮助用户系统化掌握开发与运维所需的核心技术。
深度技术解析
拒绝泛泛而谈,深入技术细节与实践难点。无论是数据库优化还是服务器配置,均结合真实场景与代码示例进行剖析,致力于提供可直接应用于工作的解决方案。
专业领域覆盖
精准对应开发生命周期。从前端界面到后端编程,从数据库操作到服务器运维,形成完整闭环,一站式满足全栈工程师和运维人员的技术需求。
即学即用高效
内容强调实操性,步骤清晰、代码完整。用户可根据教程直接复现和应用于自身项目,显著缩短从学习到实践的距离,快速解决开发中的具体问题。
持续更新保障
专注既定技术方向进行长期、稳定的内容输出。确保各栏目技术文章持续更新迭代,紧跟主流技术发展趋势,为用户提供经久不衰的学习价值。