背景:需要实现一个判断设备是否能够上网的功能,可能的实现方案有ping、DNS解析、http get等之一或者是几个结合起来一起判断,我们这里讲其中的一步,DNS解析。解析DNS可以作为判断设备能够上网的前提条件使用,如果没有这一步,像ping、http get等操作就不具备实施条件,除非你说你用ip作为参数,即使ip是通的但解析不了DNS那么上网检测的结果就失去了其意义。
在Linux系统上,使用C语言还是什么其他的什么语言去做DNS解析工作,基本都会调用到系统库函数gethostbyname,而这个函数是阻塞的,当DNS服务器不可达时,阻塞的时间是15秒钟。你的程序是否能够忍受这15秒的时间呢,用户是否能够容忍15秒后才告诉他答案,或者测试构造了一个DNS服务器不可达的环境发现你的检测进程阻塞在那里而导致依赖于这个检测结果的相关功能出现异常的时候,你要不要去解这个问题呢?我想大多数程序没有考虑到这个问题,因为gethostbyname用于太多的开源代码中(只要我们在命令行或配置参数中配置的服务器等地址使用的是域名),而大部分场景都是正常工作的,我们都假定一个前提,网络是好的,而网络是异常时这个程序工作异常也是正常情况。
而我们做一个商用产品的价值之处在于如何更好地提升用户的体验,而不仅仅是可用。产品经理会想尽办法让用户感受到产品的价值,尤其是在出现异常用户不知所措的时候告诉用户,甚至是精准告诉用户,是哪里哪一步出现问题了,而不只是反映网络连接断开,请检查你的网络。程序员为了满足这种价值将竭尽全力在各种条件和逻辑间作处理及转换,此所谓业务流程、逻辑、场景而非协议、模块等比较原子的东西。所以你是否可以理解一个做了N年产品的程序员他的积累或价值往往不是他能徒手写某个算法或者跟你说他印象最深刻或最有成就感的一个案例,也许你试图引导他深入一步步去剖析这个案例以探寻到他的深度。但你可能会失望,话说这些边缘的设备一些边缘的细节(虽然现在换了个名字叫边缘计算),可能比起大数据、虚拟化、AI之类的你也不感兴趣。
gethostbyname函数的阻塞情况分析:
1)socket(udp)
2)connect
使用DNS服务器地址和端口进行连接,udp的connect作用和本文相关的大概两个:
1、connect会做路由查询,如果同网段则走接口路由,返回成功。如果不同网段,则为该套接口查找下一跳路由,一般是默认路由,如果没有找到下一跳路由,则connect阶段就会出错,返回网络可不达,跳到第6)步,也可以理解为这是网络异常的时候第一次快速退出的地方。反之,不同网段而有下一跳路由时,返回成功。
2、udp connect后,当send发送数据时,如果对端存在异常时,可以收到对端异步返回的错误信息,比如感知到ICMP报文的端口不可达信息,而非connect对这个异步信息是无感知的,这样的一个好处是有网络错误也可以收到并退出,不用一直阻塞recv。
3)send
由于udp无连接机制,此处都是成功的,而如果非connect情况下,此处应该调用sendto,sendto其实会隐式调用如connect一样的查找路由过程,如上述2)1、中一样处理。
4)poll(wait 5 seconds)
5)recv
到了这一步,说明网络是可达的,这里的可达有两种情况:
1、端到端可达,就是设备和DNS服务器是可达的,这个时候的异常也有两种:端口不可达,比如DNS服务器主机并没有运行DNS服务器,所以,此时将会返回端口不可达信息,而connect情况下,poll会收到该信息并置于错误句柄集合中,recv将返回-1,该错误信息一般是ECONNREFUSED (Connection refused);DNS代理服务器工作正常,但是DNS代理的上级不可达,无法完成正常的DNS查询,但此时DNS代理服务器收到下级的DNS查询后,如果他知道无法完成转发查询,则一般会返回RCODE=5(Refused)报文,此时recv也能正常收到该返回报文,因为没对域名解析成功,gethostbyname会设置h_errno为HOST_NOT_FOUND。这两种情况都不会造成阻塞。
2、下一级路由不可达,此时必然阻塞,因为此时设备不会收到任何控制和数据报文,需要等待epoll超时才能退出,epoll超时会直接调到第6步。
3、下一级就是目标DNS服务器主机,但不可达,此时必然阻塞,原因同上。
4、下一级路由可达但是DNS服务器主机不可达,可能数据报文到达某一跳时,会返回ICMP Network is unreachable错误,但是该错误无法到达recv接口,即使是connect情况下,因为该ICMP的IP元组信息和connect保存的IP元组信息无法对应(因为DNS服务器主机不可达,所以ICMP的错误信息是由另外一台机子返回,而非DNS服务器主机返回),所以此时也必然阻塞。
6)close
7)goto 1)2次,加初始时一次,一起执行3次,最长阻塞约15秒钟。
鉴于有上述异常,为了不让这个问题产生在用户环境,只能放弃gethostbyname,自己实现一个gethostbyname用于解决联网检测需求。下面是lua 写的Demo代码,上面想清楚了,写代码并不耗什么时间(但是在发送DNS报文的处理上还是颇费周章,最终想到使用文件来处理二进制的方法,对lua还需要更多了解)。
--[[dns_data_bin是dns报文的文件名
dns_host是dns服务器主机的ip
dns_port是dns服务器的端口
超时时间设置为2,最长2秒钟返回检测结果
]]
local function check_dns(dns_data_bin, dns_host, dns_port)
local recv_data, ret, err
--[[lua操作二进制组装DNS报文比较困难,所以
将DNS报文的二进制文件保存于文件,由lua读出到变
量]]
local dns_file = io.open(dns_data_bin,"rb")
local len = dns_file:seek("end")
dns_file:seek("set",0)
local dns_data = dns_file:read("*a")
dns_file:close()
local socket = require("socket")
local sock = assert(socket.udp())
--[[设置超时时间,只对receive有效,其实改gethostbyname就是为了改超时时间]]
sock:settimeout(2)
ret, err = sock:setpeername(dns_host, dns_port)
if(ret == nil) then
--[[setpeername做的事情就是connect,ret为nil时,表示connect失败]]
print(dns_host, dns_port, err)
--[[返回失败]]
return nil
end
--[[通过send发出DNS报文]]
sock:send(dns_data, len)
recv_data, err = sock:receive()
sock:close()
--[[检查是否有数据返回]]
if(recv_data == nil) then
--[[没有数据返回,此处即超时返回]]
print(dns_host, dns_port, dns_data_bin, err)
return nil
else
--[[有数据返回,但是此处需要解析返回数据,由于lua操作二进制不便,这里简单以长度做判断]]
len = string.len(recv_data)
print(dns_host, dns_port, dns_data_bin, len)
if(len < 40) then
--[[此处即为返回Refused的情况]]
return nil
else
--[[返回成功]]
return 1
end
end
end
也说下另一个常见的方法:siglongjmp,这里不详述,该方法有一个问题,siglongjmp跳过当前的gethostbyname后,无法将gethostbyname中打开的socket关闭,如果使用频繁,将会消耗系统的句柄资源,出现资源泄露,这个可以使用netstat命令查看便知。这也是这个检测需求最终没有使用c语言实现的原因之一。