本文是《Nodejs开发进阶-扩展网络模块》的第三个部分,主要内容是DNS。和前几个一样,这些内容都属于底层协议和技术,在实际工作中应用的场景和机会并不是很多,所以本文中主要做概念和了解性的探讨,不会过于深入。想要了解更深入详细的内容,可以参考nodejs官方技术文档的相关章节。
概述
DNS(Domain Name System)即域名系统,它最主要的用途(实际上远不止如此,后面有更详细的内容),是将URL中的主机名称,解析成为一个公共网络的IP地址,让应用程序可以使用TCP协议基于这个IP地址来进行工作。
理论上而言,DNS解析的功能,一般在操作系统的层面来实现,这个操作,对于应用程序而言是透明的,所以应用程序的开发通常无需考虑这个问题。
但由于可能是由于初始设计比较早的原因,或者nodejs的设计和开发人员,觉得有必要提供一定的可扩展性和灵活性,所以在nodejs中,包含和实现了DNS解析相关的功能,它通过DNS模块来提供。
从nodejs的文档来看,它提供的功能还是比较丰富而全面的。但由于这个模块笔者在日常开发中确实基本上没有使用,也可以猜想对于一般的开发者也不会接触太大,所以本文中,我们只做一个基本和简单的探讨,只讨论最常用的场景和最基础的功能。这些功能主要包括查找、解析、反向解析等等。
工作原理
这里先简单解释和说明一下DNS系统的工作原理。从整体上来看,DNS查询可以分为DNS客户端和DNS服务两个大的逻辑部分。DNS客户端使用用户提供的域名(如在浏览器中输入,或者配置在电子邮件客户端软件中的域名),向DNS服务发起查询请求,DNS服务响应查询的结果。当然,真实的情况并没有这么简单。
真实的情况是,要使DNS系统能够正常高效的工作,需要由多个不同层次和功能的服务器和网络系统构成的。它包括了一个DNS递归器(也称为递归解析器)、根服务器、顶级域名 (TLD) 名称服务器和权威服务器四种类型的DNS服务器。递归器就是表面上为客户端配置的那台DNS服务器,它负责接收客户端的查询请求,并使用递归的方式,来完成真实的解析过程。首先,递归器会解析域名查询请求的内容,分解出其中的顶级域名,并向根服务器查询所归属顶级域名服务器的地址;然后,递归器进一步向顶级域名服务器查询域名所归属的权威服务器地址;然后,向这个权威服务器查询并获得域名记录,这一般就是最后的查询结果,递归器会将其发送给客户端来完成整个查询过程。此外,DNS查询还有迭代的工作模式,即迭代器本身不执行查询,而是将客户端引向上级服务器来进行查询的方式。所有层次的DNS查询,都使用相同的网络协议和模式,DNS服务默认的端口是53。
可以看到,一个完全初始和完整的查询过程是比较复杂的,涉及很多的网络传输和信息查询工作。为了提高查询效率,DNS系统使用分层次的缓存机制,每次正确的查询都会被缓存到本层次的服务器上(甚至包括查询客户端),并且使用TTL记录来控制DNS记录的缓存时间,来保证实时有效。配合分层次的部署方式,可以大幅度减少每个层次查询所需要的操作。此外,DNS使用的传输协议是UDP,相对比较轻量和高效,其可靠性和可用性是在应用层面实现的。
DNS记录和解析类型
DNS协议是一个比较古早的协议,它基本上使用简单文本的方式来进行工作,比如域名信息存储和网络传输,这些内容通常称为DNS记录。一条记录,就是一行格式化的文本信息,它的结构基本如下:
<域名> <TTL设置> <类别> <类型> <后续数据字段大小> <解析内容,如IP>
如:
www.example.com. 69288 IN A 93.184.216.34
example.com. 69288 IN CNAME www.example.com
从这里我们也可以看到,DNS并不只是具有域名映射和查询功能,它还是一个可扩展的信息存储和查询系统,通过不同的信息记录类型和配置,来满足很多网络应用寻址甚至配置信息查询的需求。
以上面的记录为例,这里先有一条A记录(域名-IP映射),TTL是69288秒,它的类别是IN(internet,一般都是这个),解析内容是93.184.216.34,即这条记录表示应该将 www.example.com 这个域名指向IP地址93.184.216.34。还有一条CNAME记录,这是别名设置,可以将example.com 指向 www.example.com ,这时如果解析这个域名,它先指向 www.example.com 然后再解析到IP地址。
其他常见的DNS记录类型包括:
记录代码 | 用途和说明 |
---|---|
A | 默认,最常用的域名映射 |
AAAA | A记录的IPv6版本,可以将域名映射到IPv6的地址 |
CNAME | 别名,可以将一个域名映射到一个域名,如服务器多域名 |
NS | 指定管辖当前域名的DNS服务器的地址 |
MX | 用于电子邮件服务,指定当前域名的电子邮件服务器地址 |
PTR | 反向解析 |
TXT | 包含供用户或机器可读信息的文本(如Meta信息),一个域可以有多个TXT记录 |
SOA | Start Of Authirity,标识DNS区域文件的开始 |
SRV | 服务记录,为指定格式提供易于查询的统一格式记录,常用于应用某种服务的位置 |
在了解了DNS的基本原理,流程和操作等方面的内容之后,我们来看看在nodejs中,对于DNS协议操作的实现。它是基于dns这个模块的。相关的主要操作包括Lookup、Resolve等。
Lookup 查找
Lookup即查找,就是查询一个域名所对应的IP地址。按照官方文档的解释,这个功能在底层调用了系统级域名查询功能,所以实际上它应该是在操作系统基本实现的,在nodejs中,只是做了一个简单的调用和封装。所以这个能力的使用效果,完全取决于操作系统的实现方式,开发者是无法进行配置和控制的。
在nodejs的dns模块中,使用lookup方法来实现查找,使用的示例代码如下:
js
const dns = require('node:dns');
dns.lookup('example.org', (err, address, family) => {
console.log('address: %j family: IPv%s', address, family);
});
// address: "93.184.216.34" family: IPv4
Resolve 解析
笔者理解,解析和查找的一个显著的区别,就是解析操作,是真正的DNS协议的实现。实际上,基于轻量化和效率的考虑,一般的DNS解析操作基于UDP协议,实际上是UDP协议的应用层的扩展协议。
一个完整的域名解析操作,需要先配置本地的DNS服务器地址;然后在需要进行域名解析的时候,一般会先查看本机hosts文件中的内容,然后查询本地缓存的DNS记录;如果都没有找到,才真正的通过网络向本地DNS服务器发起查询请求;本地DNS服务器收到查询请求后,会以递归的方式来执行这个查询,直到找到匹配结果,或者返回查询失败或者超时错误。
根据上面简单的原理和流程解释,我们可以看到在dns模块中,实现的示例代码如下:
js
const { Resolver } = require('node:dns');
const resolver = new Resolver();
resolver.setServers(['4.4.4.4']);
// This request will use the server at 4.4.4.4, independent of global settings.
resolver.resolve4('example.org', (err, addresses) => {
// ...
});
这段代码的要点包括:
- 在解析开始之前,需要实例化一个解析器对象resolver
- 可以为这个解析器对象,设置一个或者多个DNS服务器
- 然后调用解析方法,来执行解析并从回调方法中获得解析结果
- 可选解析IPv4地址或者IPv6的地址
类型解析
我们前面在讨论DNS原理的时候,已经了解到,通过不同的DNS记录类型,DNS还可以用于除了普通域名解析之外,很多其他类型的信息解析。在nodejs的dns模块中,它们是通过提供resolve* 系列函数来实现的。它们分别是:
- resolver.resolve()
- resolver.resolve4()
- resolver.resolve6()
- resolver.resolveAny()
- resolver.resolveCaa()
- resolver.resolveCname()
- resolver.resolveMx()
- resolver.resolveNaptr()
- resolver.resolveNs()
- resolver.resolvePtr()
- resolver.resolveSoa()
- resolver.resolveSrv()
- resolver.resolveTxt()
坦率的讲,笔者并不是特别理解和认同这种"简单粗暴"的设计和实现方式。完全可以通过一个参数来控制解析的类型啊,参数可以使用枚举型,可以在规范的同时,保证很好的可扩展性。
Promise
dns模块设计了Promise的使用方式,它是通过dnsPromises对象实现的,参加下列示例代码:
js
const dnsPromises = require('node:dns').promises;
dnsPromises.lookupService('127.0.0.1', 22).then((result) => {
console.log(result.hostname, result.service);
// Prints: localhost ssh
});
使用这个Promise对象,就可以使用.then().catch()这种方式来调用和执行异步解析操作了。
一个有意思的地方是,dnsPromises对象,提供了一个resolveAll()方法,可以获得当前这个域名相关的所有DNS记录的内容(但是注意却没有正常的resolveAll()这个方法),解析结果可以以数组方式呈现:
js
[ { type: 'A', address: '127.0.0.1', ttl: 299 },
{ type: 'CNAME', value: 'example.com' },
{ type: 'MX', exchange: 'alt4.aspmx.l.example.com', priority: 50 },
{ type: 'NS', value: 'ns1.example.com' },
{ type: 'TXT', entries: [ 'v=spf1 include:_spf.example.com ~all' ] },
{ type: 'SOA',
nsname: 'ns1.example.com',
hostmaster: 'admin.example.com',
serial: 156696742,
refresh: 900,
retry: 900,
expire: 1800,
minttl: 60 } ]
这个结果也可以说明,不同的解析方法和类型,解析结果的数据的组成结构,也可能是不一样的,有很多和DNS类型相关的结构,需要开发者使用时进行了解和掌握。
反向解析
可以使用resolver.reverse(ip)或者dnsPromises.reverse(ip)等方法,来执行反向解析。例如:
js
const
dnsPromises = require('node:dns').promises,
IP = "204.79.197.200"; // bing.com
//"8.8.8.8";
//"110.242.68.66";
dnsPromises
.reverse(IP)
.then(r=>{
console.log(r);
})
.catch(err=>{
console.error(err);
})
// 结果是
// [ 'a-0001.a-msedge.net' ]
获得的结果是一个数组,这很好理解,因为多个域名可以指向同一个IP地址。但不好理解的地方是,这个IP地址的原始域名,应该是"bing.com"。顺便提一下,8.8.8.8 我们都知道是google提供的域名解析服务,但它的反向解析内容是 "dns.google",这是一个合理的域名吗?
DNS错误
nodejs官方技术文档,还提供了dns的错误代码,从中我们就可以理解DNS技术的复杂性,也可以作为我们设计一个系统,相关错误处理的思维框架。
- dns.NODATA: DNS服务器返回无数据的结果
- dns.FORMERR: DNS服务器声明查询请求格式错误
- dns.SERVFAIL: DNS服务器返回一般错误
- dns.NOTFOUND: 域名记录未找到
- dns.NOTIMP: DNS服务器没有实现请求的操作
- dns.REFUSED: DNS服务器拒绝查询
- dns.BADQUERY: DNS查询格式错误
- dns.BADNAME: 主机名称格式错误
- dns.BADFAMILY: 不支持的地址家族
- dns.BADRESP: DNS响应格式错误
- dns.CONNREFUSED: 无法联系DNS服务器
- dns.TIMEOUT: DNS服务器连接超时
- dns.EOF: 文件结束
- dns.FILE: 读取文件错误
- dns.NOMEM: 内存溢出
- dns.DESTRUCTION: 通道被销毁
- dns.BADSTR: 错误的字符串
- dns.BADFLAGS: 非法标记
- dns.NONAME: 给定的主机名IP地址格式不合法
- dns.BADHINTS: 错误的提示标记
- dns.NOTINITIALIZED: c-ares库未完成初始化
- dns.LOADIPHLPAPI: iphlpapi.dll加载错误
- dns.ADDRGETNETWORKPARAMS: 无法找到GetNetworkParams方法
- dns.CANCELLED: DNS查询取消
经过查询实现代码,知晓到它并没有使用如HTTP状态码那样的整数常数,而是使用ASCII文本短语就是字符串来表示这些内容。
小结
本文谈到了如何在nodejs中实现DNS的操作和其DNS模块,包括了DNS的基本工作和操作原理、记录类型、相关方法和参数,错误信息等内容。