9.15. 名稱與 DNS

到目前為止,每一個頁面都使用數字 IP 位址——例如 192.168.1.20 之類。真實的應用程式幾乎從不這麼做。伺服器被命名為 example.comapi.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 時:

  1. 相機向其所設定的 DNS 伺服器送出一個小型 UDP 資料包(沒錯,是 UDP——見 UDP -- 送出封包,聽天由命)。該 DNS 伺服器的位址來自同一次 DHCP 交換,也就是把相機自己的 IP 交給它的那一次。

  2. DNS 伺服器可能已經把答案快取起來了(最近被問過)。若是如此,它會立即回覆。

  3. 若沒有,DNS 伺服器便走遍全域 DNS 階層:向 根(root) 伺服器詢問 .com,向那些伺服器詢問 example.com,再向 那些 伺服器詢問該名稱。整個樹狀走訪對相機是隱藏的;相機只看到一個查詢與一個回覆。

  4. 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-tuple 組成的 list(每個已解析的位址各一個);[0] 挑選第一個,[-1] 挑選最後一個欄位,也就是可直接交給 socket 的 (ip, port) tuple:

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 portal)。 有些 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(mDNS)就是填補這道缺口的系統。每一個參與的裝置都加入本地網路上一個特殊的多播群組並監聽查詢;當某個裝置詢問一個以 .local 結尾的名稱時,擁有該名稱的那個裝置便直接作答。沒有中央伺服器,也不需 DNS 設定。Apple 裝置上的 Bonjour、Linux 上的 Avahi 以及 Windows 10 以上版本,全都使用同一套通訊協定——這正是為什麼上一節所設定的 kitchen-cam.local 名稱,能在一個未額外設定任何東西的家用網路上解析成功的原因。

    相機在該交換中所扮演的是 回應器(responder),而它已經在執行了。相機所沒有的是 解析器(resolver)——即另一半,能讓指令碼向網路詢問「printer.local 在哪裡?」並得到回應。隨附的 mDNS 程式碼僅有回應器(嵌入式裝置通常是 被尋找的東西,而非進行尋找的東西)。當探索必須往另一個方向流動時,對於本地網段的情況,UDP 廣播(見 UDP -- 送出封包,聽天由命)是較簡單的答案,或者像 cbrand/micropython-mdns 這樣的純 Python 模組則可加入完整的解析器。

  • IPv6 名稱。 getaddrinfo() 若 IPv4 與 IPv6 結果都可用,便會兩者皆回傳。請挑選應用程式的 socket 所能使用的族系。

對大多數相機端的程式碼而言,getaddrinfo 是任何開啟網路連線之函式頂端的一行程式。本節別處那些針對 "192.168.1.20" 執行的範例,全都能以同樣的方式針對像 "api.example.com" 這樣的公開名稱運作——只要先解析即可。