contributed by <kaeteyaruyo
>
Computer Networking
目前為止我們已經看過了不少重要的網際網路應用程式了,現在讓我們來看看這些網路應用程式是到底是怎麼被開發出來的。我們在 2.1 節當中有提到,一個典型的網際網路應用服務是由一組成對的程式 (program) —— 客戶端程式和伺服器端程式 —— 所組成的,這兩支程式分別坐落在兩個不同的終端系統上。當這兩支程式被執行時,就會分別生出客戶端程序 (process) 和伺服器端程序,而這兩支程序接著就會藉由對彼此的插座 (socket) 進行讀取、寫入的方式來進行通訊。也因此在開發一個網際網路應用程式時,開發者最主要的任務當然就是寫出客戶端和伺服器端程式的程式碼了。
網際網路應用程式有兩種。第一種程式實作出了特定的通訊協定標準所規範的操作,通訊協定標準有可能是 RFC 或是其他的標準文件。這類型的應用程式有時候會被說是「開放的 (open)」,因為程式操作的規則是眾所皆知的。在這類型的實作中,客戶端和伺服器端程式必須要遵守 RFC 當中所描述的規定。例如:客戶端程式可以是一個 HTTP 協定客戶端的實作,HTTP 客戶端我們在 2.2 節當中有提到,那些操作被嚴謹地定義在 RFC 2616 當中;類似地,伺服器端程式也可以是一個 HTTP 協定伺服器端的實作,操作同樣被嚴謹地定義在 RFC 2616 當中。如果一個開發者寫了客戶端的程式,另一個開發者寫了伺服器端的程式,而且這兩個人都很仔細的遵守 RFC 定義的規則的話,那他們兩個的程式就可以互相溝通。事實上,現今許多牽涉到客戶端和伺服器端互動的網際網路應用程式都是由各個獨立的開發者分別實作出來的,例如:Google Chrome 瀏覽器可以跟 Apache 網頁伺服器互動,或是 BitTorrent 客戶端可以跟 BitTorrent tracker 互動。
另一種網際網路應用程式則是專有軟體。在這類型的應用程式中,客戶端和伺服器端程式執行的應用層協定是沒有被公開發表在任何 RFC 或是其他標準文件上的。一個單獨的開發者(或是開發團隊)自行實作出客戶端和伺服器端兩支程式,而且這個開發者對於程式碼有完全的掌控。但也因為這些程式沒有實作開源的通訊協定,其他的獨立開發者也就沒有辦法實作出可以跟這些應用程式互動的程式碼了。
在本節當中,我們將會深入了解在開發主從式架構應用程式時最關鍵的幾個議題,並透過觀察一個實作了非常簡單的主從式架構程式的程式碼來「親身體驗」。在開發階段,開發者要面臨的第一個決策就是:這個應用程式到底應該運作在 TCP 還是 UDP 之上呢?先前我們有提到,TCP 是連線導向的 (connection-oriented) 協定,提供可靠的位元流頻道讓資料可以在兩個終端系統之間流通。而 UDP 則是無連線的 (connectionless) 協定,每一個資料封包都是被獨立的從一個終端系統送到另一個終端系統,並且沒有任何一定會送達的保證。我們先前也有提過,當一個客戶端或伺服器端程式實作了 RFC 的規範,他就必須要使用大家都知道的埠號來進行傳輸;反過來,如果是在開發專有軟體的話,那些通訊協定就一定要避免去用到這些大家都在用的埠號(關於埠號,我在 2.1 節時已經有稍微提過,更多細節我們將會在第三章進行說明)。
接下來我們會透過分別介紹一個簡單的 UDP 應用程式和一個簡單的 TCP 應用程式來介紹 UDP 和 TCP 的插座程式設計 (socket programming)。我們介紹的簡單程式碼會是用 Python3 實作的。這支程式也是可以用 Java、C 或是 C++ 來實作,但我們之所以選擇 Python 是因為 Python 可以很清楚的展現 socket 關鍵性的概念。用 Python 來實作,程式碼只會有短短的幾行,而且每一行都可以很容易地解釋給新手開發者理解。但如果你跟 Python 很不熟也千萬不用緊張。只要你曾經有使用 Java、C 或 C++ 寫程式的經驗,你一定可以很簡單地看懂我們提供的程式碼。
如果你對於用 Java 實作主從式架構的應用程式感興趣,我們推薦你去看看這本書隨附的網站;事實上,你可以在這個網站上找到本節中所有的範例(和練習)的 Java 程式碼。而如果是對用 C 實作主從式架構應用程式感興趣的讀者,網路上也是有很多很好的資源可以參考 [Donahoo 2001; Stevens 1997; Frost 1994; Kurose 1996];我們底下的 Python 範例看起來和感覺起來和用 C 寫的程式碼是很相似的。
在這個子節中,我們會來寫一個簡單的,使用 UDP 的主從式架構程式;而在接下來的子節中,我們則會用 TCP 實作出另一個相似的程式。
在 2.1 節中我們提到,兩個執行在不同機器上的程序要互相溝通時,會往對方的 socket 傳送訊息。我們當時把程序比喻成是一間房子,而 socket 就是這個房子的大門。應用程式坐落在大門的一側,在房子裡面;而傳輸層協定則在大門的另一側,在外面的世界。應用程式的開發者對於在應用層協定這一側的 socket 有完全地掌控權;但是對於傳輸層那一側的 socket,就沒有什麼能控制的了。
接著讓我們來看看兩個使用 UDP 插座進行通訊的兩支程序是怎麼互動的。使用 UDP 時,在送信者得以把資料封包推出 socket 門外之前,他必須要先把目地第的地址先貼到封包上才行。在封包被推出送信者的 socket 門外之後,網際網路就會利用這個目的地的地址來進行路由,透過網際網路把封包送到收信程序的 socket 去。一旦封包抵達收信的 socket,收信程序就會透過 socket 把封包收進來,查看封包的內容並採取對應的行動。
那現在你可能就會開始想,那個要貼到封包上的目的地地址要放什麼?就跟你猜的一樣,目標主機的 IP 位址會是這個目的地地址的一部份。透過把目標的 IP 位址貼到封包上,網際網路當中的路由器就可以透過網路把封包一路轉發到目標主機去了。但因為目標主機上可能會同時執行很多不同的應用程序,每一個程序可能都會使用一到多個 socket,因此辨識出我們要溝通的特定 socket 是哪一個也是有必要的。每當一個 socket 被建立起來,電腦都會指定一個辨識符給它,也就是埠號 (port number)。所以接下來就跟你想的一樣了,封包的目的地地址也包含了 socket 的埠號。總結來說,送信方的程序會在封包上貼上一個目的地地址,這個地址包含了目標主機的 IP 位址和目標 socket 的埠號。再來,我們接下來也會看到,送信者的來源地址(由來源主機的 IP 位址和來源 socket 的埠號所組成)也會被貼到封包上。不過一般來說,把來源地址貼到封包上這個動作通常不是由使用 UDP 的應用程式來進行的,而是由底下的作業系統自動完成這個動作。
我們用以下這個簡單的主從式架構應用程式來示範使用 TCP 和 UDP 的 socket 程式設計是怎麼進行的:
Figure 2.27 使用 UDP 進行通訊的主從式架構應用程式
Figure 2.27 特別標出了在客戶端和伺服器端透過 UDP 傳輸服務通訊的過程中,跟 socket 有關的幾個主要活動。
現在讓我們來親身體驗,看看利用 UDP 實作了這個系統的那一對主從式架構程式是怎麼寫的。我們同時也提供了關於程式碼非常詳盡的、逐行的分析。我們會先從 UDP 客戶端開始看起,這支程式會送出一個簡單的應用層訊息給伺服器端。伺服器端為了要可以收到並回覆客戶端訊息,它必須要做好準備並處於執行狀態 —— 也就是說,在客戶端送出訊息之前,它就必須要是一個已經正在執行的程序。
客戶端程式的檔名叫作 UDPClient.py,而伺服器端程式的檔名則是 UDPServer.py。為了要強調最關鍵的那些議題,我們有意地提供了最小化的程式碼。但一份「好的程式碼」是可以有更多行輔助用的程式碼的,特別是處理錯誤用的那些程式碼。對於這支應用程式,我們隨意取用了 12000 作為伺服器的埠號來使用。
下列程式碼實作了應用程式的客戶端:
現在讓我們來看看 UDPClient.py 當中的每一行程式碼。
socket
模組是 Python 當中組成所有網路通訊功能的基礎。我們需要這行程式碼,讓我們可以在我們的程式當中創造出 socket。
第一行將變數 serverName
設定成 'hostname' 這個字串。在這裡,我們提供的字串要嘛是這台伺服器的 IP 位址(例如:"128.138.32.126"),要嘛是這台伺服器的主機名稱(例如:"cis.poly.edu")。如果我們提供的是主機名稱,那麼稍後 DNS 就會自己去找出這台伺服器的 IP 位址。第二行則將變數 serverPort
設定成 12000。
譯註:從此處說明可以得知,serverName
是指目標伺服器所在的主機名稱。若讀者測試程式碼時是在自己的電腦上同時執行伺服器與客戶端,則伺服器所在的主機就是本機,因此 serverName
應該要設定為 "localhost"
或是 "127.0.0.1"
,程式才能正常執行。
這一行用來創造一個客戶端的 socket,變數名稱叫作 clientSocket
。第一個參數用來指定所使用的位址家族;具體來說,AF_INET
代表底下的網路用的是 IPv4(現在你不懂也沒關係,我們在第四章會說明)。第二個參數則代表這個 socket 是 SOCK_DGRAM
這種類型的 socket,也就是 UDP 用的 socket(而不是 TCP 的 socket)。值得注意的是,當我們創造出客戶端的 socket 時,我們並沒有給他指定一個埠號,反而是讓作業系統幫我們做完這件事。現在客戶端程序的大門已經蓋好了,接著我們就可以來創造一個要通過這扇門寄出去的訊息。
raw_input()
(譯註:在 Python3 中已經被整合進 input()
)是 Python 的一個內建函式。當這道指令被執行時,使用客戶端的使用者就會看到一條提示訊息寫 "Input lowercase sentence:"。接著使用者必須用他的鍵盤輸入一行文字,這些字接著會被放進變數 message
當中。現在我們已經有了 socket 和訊息了,我們就可以來把這個訊息透過 socket 傳送給目標主機。
在上面這行當中,我們首先把訊息從字串型別轉換成了位元組型別,因為我們送進 socket 裡的東西必須要是位元組;這個動作透過呼叫 encode()
方法來完成。接著 sendto()
方法把目的地地址 (serverName, serverPort)
貼到訊息上,並將成品的封包送進程序的 socket clientSocket
當中(就像先前說的,來源地址也是有被貼到封包上,但是是自動完成的而不是顯式地透過程式碼完成的就是了)。用 UDP socket 傳送一個主從式架構的訊息就是這麼簡單!在送出封包後,客戶端接著接收從伺服器端傳回來的資料。
上面這一行的意思是,當有封包透過網際網路送到了客戶端的 socket 門口時,封包的資料就會被放進變數 modifiedMessage
當中,而封包的來源地址則會被放進變數 serverAddress
當中。變數 serverAddress
同時包含了伺服器的 IP 位址還有伺服器的埠號。對於 UDPClient 這支程式來說,它其實不需要這些伺服器地址的資訊,因為它打從一開始就知道伺服器的地址了;但這行 Python 程式碼才不管那麼多,它總之就是會回傳這個資訊出來。而 recvfrom
這個方法也吃了一個緩衝區大小 2048 作為輸入(這個緩衝區大小對於大部分的用途來說已經夠大了)。
這行程式碼會把收到的訊息 modifiedMessage
從位元組解碼回字串後,再顯示到使用者的螢幕上。顯示出來的訊息應該會是使用者原本輸入的訊息所有文字都被轉換成大寫的版本。
這行程式碼會關閉 socket。整個程式到此結束。
接著讓我們來看看伺服器端的程式碼:
我們會看到 UDPServer 的開頭和 UDPClient 是長得很像的。他們同樣都引入了 socket 模組、同樣都把變數 serverPort
設定成 12000,也同樣都創建了一個 SOCK_DGRAM
類型的 socket(UDP socket)。第一行長得跟 UDPClient 非常不一樣的程式碼是:
上面這行程式碼將埠號 12000 綁定 (bind)(也就是指定)到伺服器的 socket 上。因此在 UDPServer 當中,是由(開發者所寫的)程式碼顯式地把埠號指定到 socket 上的。藉由這個動作,所有送到伺服器的 IP 位址 12000 埠口的封包,通通都會被導向到這個 socket。接著 UDPServer 會進入一個 while 迴圈,這個 while 迴圈讓 UDPServer 可以無止盡地從客戶端那邊接收並處理封包。在這個 while 迴圈當中,UDPServer 會等待封包抵達。
這一行跟我們在 UDPClient 當中看到的很像。當封包抵達了伺服器的 socket 後,封包的資料會被放進變數 message
裡面,而封包來源地址則會被放進變數 clientAddress
裡面。變數 clientAddress
裡面會同時包含客戶端的 IP 位址和所使用的埠號。在這裡,UDPServer 是會用到這些地址資訊的,這些資訊就是稍後的回傳地址,就跟使用傳統郵政系統時會需要填寫回郵地址一樣。有了這個來源地址的資訊,伺服器就知道稍後要把訊息回應到哪裡去了。
這一行是我們這支簡單的程式最重要的核心。他會吃進使用者寄來的訊息,把他轉換成字串型別後,再用 upper()
方法把訊息轉換成大寫。
最後一行程式碼用來將客戶端的地址(IP 位址和埠號)貼到已經轉為大寫的訊息(在這條訊息已經被轉成位元組型別後)上,並將成品的封包送進伺服器的 socket 中(就像先前說的,伺服器的地址也是有被貼到封包上,但是是自動完成的而不是顯式地透過程式碼完成的)。接著網際網路就會把封包送到客戶端的地址去。在伺服器送出封包之後,它又會進到下一輪的 while 迴圈當中,持續等待另一個 UDP 封包抵達(這個封包可以是由跑在任何主機上的客戶端傳的)。
要測試這對程式,你可以在一台主機上面執行 UDPClient.py,然後在另一台主機執行 UDPServer.py。記得在 UDPClient.py 裡面要寫上適當的主機名稱或是 IP 位址。接著,在伺服器主機上執行 UDPServer.py。這個動作會創造出一個伺服器程序,這隻程序會維持閒置,直到有任何客戶端聯絡他才會開始動作。接著你就可以在客戶端主機上執行 UDPClient.py。這個動作會創造出一個客戶端程序。最後,要使用這支應用程式的客戶端,你可以打一些字並按下換行鍵送出。
要開發出一個你自己的 UDP 主從式架構應用程式,你可以從微幅修改這組程式的客戶端或是伺服器端開始。例如:伺服器可以不要回傳訊息轉大寫的版本,可以是數一數在字串中總共出現了幾次 s,然後把出現的次數回傳回去。或者你也可以修改客戶端,讓使用者可以在收到大寫化的訊息之後,可以再繼續送出更多條句子給伺服器。
不像 UDP,TCP 是一個連線導向的協定。意思是說在客戶端和伺服器端要可以開始互相傳送訊息之前,他們必須要先進行交握 (handshake) 並建立起一個 TCP 連線。這個 TCP 連線的其中一端會附著在客戶端的 socket 上,而另一端則是附著在伺服器端的 socket 上。在創造 TCP 連線時,我們會把客戶端的 socket 地址(IP 位址和埠號)以及伺服器端的 socket 地址(IP 位址和埠號)關聯到這個連線上。一旦 TCP 連線建立起來,當有任何一方想要傳送資料給另一方時,他就只需要把資料透過 socket 丟進 TCP 連線裏面就行了。這一點跟 UDP 不一樣,在 UDP 當中,每次把封包丟進 socket 之前都一定要先把目標地址貼在封包上才可以。
現在,讓我們來看看在使用 TCP 的情況下客戶端和伺服器端是怎麼進行互動的。客戶端有義務完成一開始聯絡伺服器端的那些工作。為了讓伺服器能夠回應客戶端一開始的聯絡訊息,伺服器端必須做好準備。做好準備指的是以下兩件事情:首先,就像在使用 UDP 時一樣,TCP 伺服器必須要在客戶端聯絡它之前就是一個已經正在執行的程序;再來,這支伺服器程序必須要有一個特別的門 —— 更準確來說,要有一個特別的 socket —— 這個門必須歡迎執行在任何一個主機上的客戶端程序對它發送建立連線的聯絡訊息。以剛才的「房子/門」之於「程序/socket」這樣的對比,我們有時候會把客戶端發起連線請求這件事比喻成「在歡迎光臨的門上敲敲門 (knocking on the welcoming door)」。
在伺服器端程序正在執行的情況下,客戶端隨時可以跟伺服器要求建立 TCP 連線,這透過在客戶端程序內創建出一個 TCP socket 來完成。當客戶端要創建他的 TCP socket 時,它必須指定好伺服器端那個歡迎人家連線的 socket 地址,也就是伺服器端的主機 IP 位址以及那個 socket 的埠號。在創建好自己這邊的 socket 之後,客戶端就會開始跟伺服器端進行三向交握並建立起跟伺服器端的 TCP 連線。三向交握這個動作是發生在傳輸層的,因此對於客戶端程式和伺服器端程式來說,他們完全不會知道三向交握是怎麼進行的(是隱形的)。
在三向交握的過程中,客戶端會在伺服器端對外開放的門上敲敲門。當伺服器端「聽到」有人敲門後,他就會再創造出另一個新的門出來 —— 更精確來說,是一個新的專門用來跟這個客戶端進行通訊的 socket。在我們底下的範例當中,伺服器端那個對外開放的門就是 serverSocket
這個 TCP socket;而新創出來的專門跟要求要連線的特定客戶端進行通訊的 socket 則是 connectionSocket
。剛學到 TCP socket 的同學很容易會把開放的 socket(也就是當客戶端聯絡伺服器端說要建立連線的時指定的那個 socket)和後來才由伺服器端新建出來、用來跟各個客戶端連線用的 socket 搞混。
Figure 2.28 TCPServer 程序會有兩個 socket
從應用程式的角度來看,客戶端的 socket 和伺服器端連線用的 socket 是直接由一個管線 (pipe) 連接起來的。如同 Figure 2.28 所示,客戶端程序可以傳送任意的位元組資料進入 socket 中,TCP 會保證伺服器端程序一定會(透過連線用 socket)照傳送順序收到每一個位元組。因此我們會說,TCP 在客戶端和伺服器端程序之間提供了可靠的傳輸服務。再來,就像人類可以從同一個門進出一樣,客戶端程序不僅可以從他的 socket 送出資料,也可以從那個 socket 接收資料;同樣的道理,伺服器端程序也不只能接收資料,同樣可以透過連線用 socket 送出資料給客戶端。
Figure 2.29 使用 TCP 進行通訊的主從式架構應用程式
我們用跟剛才一樣的簡單的主從式架構應用程式來說明如何用 TCP 進行 socket 程式設計:客戶端會傳送一行文字資料給伺服器,而伺服器會把文字全部轉大寫之後回傳給客戶端。Figure 2.29 特別標出了在客戶端和伺服器端透過 TCP 傳輸服務通訊的過程中,跟 socket 有關的幾個主要活動。
下列程式碼實作了應用程式的客戶端:
註:關於 serverName
和 raw_input()
的問題與上述 UDP 版本處理方法相同。
現在讓我們來看看那幾行跟 UDP 版本的實作相差非常大的程式碼。第一個就是創造客戶端 socket 的那一行。
這一行創建了一個客戶端 socket,叫作 clientSocket
。第一個參數同樣是用來指定底下的網路要使用 IPv4。而第二個參數指定了所使用的 socket 是 SOCK_STREAM
這種類型的 socket,也就是 TCP socket(而不是 UDP socket)。注意到在這裡我們同樣沒有指定客戶端 socket 的埠號,而是讓作業系統自己幫我們完成這件事。下一行和 UDPClient 非常不一樣的程式碼是:
剛才我們有提到,在客戶端要透過 TCP socket 傳送資料給伺服器(或是反過來)之前,必須先建立一個 TCP 連線在客戶端和伺服器端之間才行。上面這行用來初始化一個在客戶端和伺服器端之間的 TCP 連線。其中 connect()
方法的參數是連線的伺服器端的地址。這一行執行完畢後,就會完成三向交握並在客戶端和伺服器端之間建立起一個 TCP 連線。
就跟在 UDPClient 當中做的一樣,上面這行程式碼讓使用者可以輸入一個句子。字串變數 sentence
會持續吃入使用者用鍵盤輸入的字元直接到使用者按下了換行鍵結束輸入為止。而下一行程式碼也跟 UDPClient 差很多:
上面這行程式碼用來將 sentence
透過客戶端的 socket 送進 TCP 連線當中。注意到程式並沒有像使用 UDP socket 那樣自己創造出封包並把目的地地址貼到封包上。客戶端程式反倒是單純把字串 sentence
當中的位元組資料丟進 TCP 連線當中,就這樣而已。接下來客戶端就會等待從伺服器端那邊接收回傳過來的資料。
當有字元從伺服器端那邊送過來時,它們會被放進字串變數 modifiedSentence
當中。傳送過來的字元會一個一個被放進 modifiedSentence
裡面,直到收到了換行字元才停止。在把轉成大寫後的字元印出來之後,我們就可以把客戶端的 socket 關掉:
這一行程式碼會把 socket 關掉,因此也一併關掉了客戶端和伺服器端之間的 TCP 連線,這個動作會觸發客戶端的 TCP 傳送一個 TCP 訊息給伺服器端的 TCP(詳見 3.5 節)。
接著讓我們來看看伺服器端的程式碼:
現在我們來看看那些跟 UDPServer 和 TCPClient 都差很多的程式碼。就跟在 TCPClient 當中一樣,伺服器端也是用以下這行程式碼來創造 TCP socket 的:
接著跟 UDPServer 類似,我們必須把伺服器使用的埠號,也就是 serverPort
,綁定到這個 socket 上:
但是在使用 TCP 的情況下,serverSocket
將會是我們對外開放的那個 socket。在建立這個對外開放的大門後,我們就會監聽並等待有客戶端跑來敲敲門:
這一行就是叫伺服器開始監聽有沒有客戶端送來 TCP 連線請求。傳入的參數代表最多可以有幾個等待處理的連線(最少要有 1 個)。
當有一個客戶端來敲了這個門,程式就會執行 serverSocket
的 accept()
方法,這個動作會在伺服器端創造出一個新的 socket,叫作 conncetionSocket
,專門用來跟這個客戶端進行通訊。接著客戶端和伺服器端兩台電腦就會完成三方交握,在客戶端的 clientSocket
和伺服器端的 connectionSocket
之間創造出一個 TCP 連線。一旦 TCP 連線建立起來,客戶端和伺服器端就可以藉由這個連線傳送位元組資料給彼此了。使用 TCP 進行傳輸,不僅可以保證所有丟進連線中的資料包一定會抵達連線的另一端,還保證這些資料一定會照順序抵達。
在這支程式中,在把修改後的句子回傳給客戶端之後,我們就會把連線用的 socket 關閉。但是因為 serverSocket
依然是開放的,因此現在另一個客戶端還是可以跑來悄悄門並傳送要修改的句子過來給伺服器。
以上就是關於使用 TCP 進行 socket 程式設計的說明。我們推薦你在兩台不同主機上執行看看這兩組程式,也可以試著修改裡頭的程式碼讓他們有稍微不一樣的功能。你可以比較看看 UDP 這組程式和 TCP 這組程式有哪裡不一樣,也可以去試著實作看看在第二章、第四章和第九章最後留給你的 socket 程式設計作業。最後,我們希望在某一天,你已經精熟這些概念以及更進階的 socket 程式之後,你可以寫出一個大受歡迎的應用程式,在你變得日進斗金且舉世聞名之後,希望你可以記得本書的作者!(很可愛XDD)
<< 2.6 Video Streaming and Content Distribution Networks | 目錄 | 2.8 Summary >>