9.15. 名称与 DNS¶
到目前为止,每一页用的都是数字 IP 地址——比如 192.168.1.20 这样的。真实的应用程序几乎从不这样做。服务器被命名为 example.com 或 api.example.com,应用程序在运行时查找该名称以找到要向其发送数据包的 IP。这种查找就是 域名系统(Domain Name System),即 DNS。
9.15.1. 名称解析到什么¶
名称只是一个标签。example.com 本身并不携带任何 IP 信息——它必须被查找,就像在电话簿里查找一个电话号码一样。DNS 基础设施就是互联网这本分布式的电话簿,一次查找的结果就是摄像头可以连接的一个或多个 IP 地址。
example.com -> 93.184.216.34
单个名称往往会解析到多个地址(用于负载均衡、地理冗余,或同一服务的 IPv4 和 IPv6 版本)。其中任何一个都可用;应用程序挑选一个并尝试,若失败则回退到下一个。
9.15.2. 查找是如何发生的¶
当摄像头请求 example.com 时:
摄像头向其配置好的 DNS 服务器发送一个小的 UDP 数据报(没错,是 UDP——参见 UDP —— 发出一个数据包,听天由命)。这个 DNS 服务器的地址来自同一次 DHCP 交互,正是那次交互给摄像头分配了它自己的 IP。
DNS 服务器可能已经缓存了答案(最近被问过)。如果是这样,它会立即回复。
如果没有,DNS 服务器就会遍历全球 DNS 层级:向 根(root) 服务器询问
.com,向那些服务器询问example.com,再向 那些 服务器询问该名称。整个树状遍历对摄像头是隐藏的;摄像头看到的只是一次查询和一次回复。DNS 服务器会缓存结果以备下次使用,并将回复作为另一个 UDP 数据报发回摄像头。
整个交互过程在缓存命中时通常只需几毫秒,在缓存未命中时则可能多达一百毫秒左右。
9.15.3. Python 接口¶
getaddrinfo() 函数执行查找,并返回一个可直接交给 socket 构造函数的地址:
import socket
addr = socket.getaddrinfo("example.com", 80)[0][-1]
print(addr)
# ('93.184.216.34', 80)
其签名为 getaddrinfo(host, port)。返回值是一个由 5 元组组成的 列表(每个已解析的地址对应一个);[0] 取出第一个,[-1] 取出最后一个字段,也就是可以直接交给 socket 的 (ip, port) 元组:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.connect(socket.getaddrinfo("example.com", 80)[0][-1])
s.send(b"GET / HTTP/1.0\r\nHost: example.com\r\n\r\n")
...
大多数与远程服务器通信的摄像头代码都从这一行开始。asyncio 的辅助函数(asyncio.open_connection())在传入名称而非数字 IP 时会在内部完成查找,因此异步代码通常不会直接调用 getaddrinfo()。
9.15.4. 可能出错的地方¶
没有可达的 DNS 服务器。 如果 Wi-Fi 刚刚接通且链路不稳定,第一次
getaddrinfo()调用可能会超时。这会抛出OSError;待链路稳定后重试一次。名称不存在。 拼写错误或过时的名称会在 DNS 服务器返回“无此名称”后抛出
OSError。错误码可将此情况与“DNS 服务器不可达”区分开,但对大多数摄像头应用来说,重试/放弃的策略是相同的。返回的地址无法工作。 DNS 可能返回一个已不再托管该服务的地址。解决办法是回退到
getaddrinfo()结果列表中的下一个条目,或稍后再次查找该名称(届时 DNS 缓存很可能已经更新)。强制门户(Captive portals)。 有些 Wi-Fi 网络会拦截 DNS,并对 所有内容 都返回强制门户页面的 IP。摄像头看起来似乎连上了,但它收到的数据将与实际服务本应发送的内容不符。这在已部署的环境中并不常见,但在会议 Wi-Fi 及类似网络上确实会发生。
9.15.5. 摄像头自己的名称¶
到目前为止的各页查找的都是 其他 设备的名称。摄像头自身也有一个名称,它在请求地址时会把这个名称通告给本地网络。默认是一个因板卡而异的通用标识符;network.hostname() 可以用某个网络中其余设备能识别的名称将其覆盖:
import network
network.hostname("kitchen-cam")
# ... then bring the link up as usual ...
请在启用接口 之前 设置主机名,这样名称就会作为摄像头初始地址请求的一部分发送出去,而不是在之后才发送。
摄像头加入网络后,现在会发生两件事。其一,大多数家用路由器会把它们分配地址时所处理的主机名注册进自己的本地查找表,于是其他设备可以通过 kitchen-cam 访问该摄像头——而不必知道路由器恰好分配的那个数字地址。(企业网络可能会也可能不会遵循这一点;具体行为取决于路由器。)其二,摄像头 自身 开箱即用地运行着一个 mDNS 响应器,因此在任何客户端理解 mDNS 的网络上——大多数现代桌面操作系统都理解——同一个名称也可以通过 kitchen-cam.local 访问。
备注
请把 裸 主机名传给 network.hostname()——也就是 "kitchen-cam",不要带 .local 后缀。.local 形式是 mDNS 在查找时添加的;把它写进主机名里会让摄像头把 kitchen-cam.local 当作一个普通主机名来通告,而这并不是查找双方所期望的。
9.15.6. 当名称还不够用时¶
有几种情形是 DNS 帮不上忙的:
本地网络上的发现。 标准 DNS 回答的是关于已在全球目录中注册的名称的问题;它对本地网段上的设备一无所知。组播 DNS(Multicast DNS,mDNS) 正是填补这一空白的系统。每个参与的设备都加入本地网络上的一个特殊组播组并监听查询;当某个设备请求一个以
.local结尾的名称时,拥有该名称的那个设备会直接应答。无需中央服务器,无需 DNS 配置。Apple 设备上的 Bonjour、Linux 上的 Avahi 以及 Windows 10+ 都使用同一种协议——这也是为什么上一节设置的kitchen-cam.local名称无需任何额外配置就能在家庭网络中解析。摄像头在那次交互中扮演的是 响应器(responder) 的角色,而它已经在运行了。摄像头所没有的是 解析器(resolver)——也就是另一半,它能让脚本向网络询问“
printer.local在哪里?”并得到答复。内置的 mDNS 代码仅有响应器功能(嵌入式设备通常是 被找到的对象,而不是去查找别人的对象)。当发现需要朝另一个方向进行时,对于本地网段的情形,UDP 广播(参见 UDP —— 发出一个数据包,听天由命)是更简单的答案,或者用一个纯 Python 模块,例如 cbrand/micropython-mdns,它增加了一个完整的解析器。IPv6 名称。 如果 IPv4 和 IPv6 结果都可用,
getaddrinfo()会同时返回两者。请挑选应用程序的 socket 能够使用的那个地址族。
对大多数摄像头侧的代码来说,getaddrinfo 只是任何打开网络连接的函数顶部的一行代码。本节其他地方那些针对 "192.168.1.20" 运行的示例,针对 "api.example.com" 这样的公共名称也会以同样的方式工作——只需先解析即可。