基於TCP協議的Socket網路程式設計( )

Ego_Bai發表於2018-07-01

TCP程式設計

Socket是網路程式設計的一個抽象概念。通常我們用一個Socket表示“開啟了一個網路連結”,而開啟一個Socket需要知道目標計算機的IP地址和埠號,再指定協議型別即可。

今天我們要在Python中,基於TCP協議進行Socket網路程式設計

客戶端

大多數連線都是可靠的TCP連線。建立TCP連線時,主動發起連線的叫客戶端,被動響應連線的叫伺服器。

舉個例子,當我們在瀏覽器中訪問百度時,我們自己的計算機就是客戶端,瀏覽器會主動向百度的伺服器發起連線。如果一切順利,百度的伺服器接受了我們的連線,一個TCP連線就建立起來的,後面的通訊就是傳送網頁內容了。


言歸正傳,如果我們需要進行網路通訊,就必須要建立一個基於TCP連線的Socket:

#######################
#########客戶端#########
#######################
import socket#匯入socket庫
import time, threading#匯入threading模組
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) #建立一個socket
s.connect(('www.baidu.com', 80))#建立連線

建立Socket時,AF_INET指定使用IPv4協議,如果要用更先進的IPv6,就指定為AF_INET6。SOCK_STREAM指定使用面向流的TCP協議,這樣,一個Socket物件就建立成功,但是還沒有建立連線。

客戶端要主動發起TCP連線,必須知道伺服器的IP地址和埠號。百度網站的IP地址可以用域名www.baidu.com自動轉換到IP地址,但是怎麼知道百度伺服器,它作為伺服器,提供什麼樣的服務,埠號就必須固定下來。由於我們想要訪問網頁,因此百度提供網頁服務的伺服器必須把埠號固定在80埠,因為80埠是Web服務的標準埠。其他服務都有對應的標準埠號,例如SMTP服務是25埠,FTP服務是21埠,等等。埠號小於1024的是Internet標準服務的埠,埠號大於1024的,可以任意使用。

因此,我們連線百度伺服器的程式碼如下

注意引數是一個tuple(元祖),包含地址和埠號。

建立TCP連線後,我們就可以向百度伺服器傳送請求,要求返回首頁的內容:

s.send(b'GET / HTTP/1.1\r\nHost: www.baidu.com\r\nConnection: close\r\n\r\n')
#傳送資料

TCP連線建立的是雙向通道,雙方都可以同時給對方發資料。但是誰先發誰後發,怎麼協調,要根據具體的協議來決定。例如,HTTP協議規定客戶端必須先發請求給伺服器,伺服器收到後才發資料給客戶端。

傳送的文字格式必須符合HTTP標準,如果格式沒問題,接下來就可以接收百度伺服器返回的資料了:

#接收資料
buffer = []
while True:
    #每次最多接受1kb
    d = s.recv(1024)#一次最多接受指定的位元組數
    if d:
        buffer.append(d)
    else:
        break
data = b''.join(buffer)

接收資料時,呼叫recv(max)方法,一次最多接收指定的位元組數,因此,在一個while迴圈中反覆接收,直到recv()返回空資料,表示接收完畢,退出迴圈。

當我們接收完資料後,呼叫close()方法關閉Socket,

# 關閉連線:
s.close()

這樣,一次完整的網路通訊就結束了;
接下來把接收到資料包括HTTP首部和網頁本身,我們只需要把HTTP首部和網頁分離一下,把HTTP首部內容列印出來,而接受到的網頁內容儲存到檔案:

header,html = data.split(b'\r\n\r\n',1)  #將HTTP首部和網頁分離
print(header.decode('utf-8'))
#把接收的資料寫入檔案
with open('baidu.html','wb') as f:
    f.write(html);

整體的客戶端程式碼如下

#######################
#########客戶端#########
#######################
import socket#匯入socket庫
import time, threading#匯入threading模組
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) #建立一個socket
s.connect(('www.baidu.com', 80))#建立連線
s.send(b'GET / HTTP/1.1\r\nHost: www.baidu.com\r\nConnection: close\r\n\r\n')#傳送資料
#s.connect(('www.sina.com.cn', 80))#新浪
#s.send(b'GET / HTTP/1.1\r\nHost: www.sina.com.cn\r\nConnection: close\r\n\r\n')#傳送資料
#接收資料
buffer = []
while True:
    #每次最多接受指定的位元組數,此處最多接受1kb
    d = s.recv(1024)
    if d:
        buffer.append(d)
    else:
        break
data = b''.join(buffer)

#關閉連線
s.close()

header,html = data.split(b'\r\n\r\n',1)  #將HTTP首部和網頁分離
print(header.decode('utf-8'))

#把接收的資料寫入檔案
with open('baidu.html', 'wb') as f:
    f.write(html);


#with open('sina.html','wb') as f:
#f.write(html)

正確執行結果如下:

接受的網頁資料成功寫入了一個html檔案中

這裡寫圖片描述

html檔案內容

這裡寫圖片描述

這裡寫圖片描述

  • 開始的時候因為程式碼縮排和傳參字串少個空格的問題,結果一直請求失敗,無法接受正確的網頁資訊

這裡寫圖片描述
這裡寫圖片描述
這裡寫圖片描述


其中with在檔案操作上的用法非常巧妙,以前不太熟悉,看的時候很困惑,所以特別瞭解了一下

With語句是什麼?

關於with我已經單獨整理到這篇部落格中:
Python中With的用法

有一些任務,可能事先需要設定,事後做清理工作。對於這種場景,Python的with語句提供了一種非常方便的處理方式。其中一個很好的例子是檔案處理,你需要獲取一個檔案控制程式碼,從檔案中讀取資料,然後關閉檔案控制程式碼。
如果不用with語句,程式碼如下:

file = open("/tmp/foo.txt")
data = file.read()
file.close()

這裡有兩個問題。一是可能忘記關閉檔案控制程式碼;二是檔案讀取資料發生異常,沒有進行任何處理。下面是處理異常的加強版本:

file = open("/tmp/foo.txt")
try:
    data = file.read()
finally:
    file.close()

這段程式碼執行良好,但是太冗長。這時候with便體現出了優勢。 除了有更優雅的語法,with還可以很好的處理上下文環境產生的異常。下面是with版本的程式碼:

with open("/tmp/foo.txt") as file:
    data = file.read()

是不是很簡單?
但是如果對with工作原理不熟悉的通許可能會和剛才的我一樣,不懂其中原理
那麼下面我們簡單看一下with的工作原理

with是如何工作的?

基本思想是:with所求值的物件必須有一個enter()方法,一個exit()方法。

緊跟with**後面的語句被求值後,返回物件的**__enter__()方法被呼叫,這個方法的返回值將被賦值給as後面的變數。當with後面的程式碼塊全部被執行完之後,將呼叫前面返回物件的exit()方法。

下面是一個例子

######################
########with()##########
######################
class Sample:
    def __enter__(self):
        print("in __enter__")

        return "Foo"

    def __exit__(self, exc_type, exc_val, exc_tb):
                    #exc_type: 錯誤的型別 
                    #exc_val: 錯誤型別對應的值 
                    #exc_tb: 程式碼中錯誤發生的位置 
        print("in __exit__")

def get_sample():
    return Sample()
with get_sample() as sample:
    print("Sample: " ,sample)

執行程式碼,輸出如下
這裡寫圖片描述

分析執行過程:

  1. 進入這段程式,首先建立Sample類,完成它的兩個成員函式enter ()、exit()的定義,然後順序向下定義get_sample()函式.
  2. 進入with語句,呼叫get_sample()函式,返回一個Sample()類的物件,此時就需要進入Sample()類中,可以看到

        1. __enter__()方法先被執行
        2. __enter__()方法返回的值 - 這個例子中是"Foo",賦值給變數'sample'
        3. 執行with中的程式碼塊,列印變數"sample",其值當前為 "Foo"
        4. 最後__exit__()方法被呼叫
    

完整執行細節的除錯過程請看gif:

這裡寫圖片描述

這裡只做了有限的簡單說明,關於python中with用法詳細參考:
淺談 Python 的 with 語句
python的with用法


說完with我們繼續回到,socket程式設計

伺服器

接下來我們實現伺服器端的過程

和客戶端程式設計相比,伺服器程式設計就要複雜一些。

伺服器程式首先要繫結一個埠並監聽來自其他客戶端的連線。如果某個客戶端連線過來了,伺服器就與該客戶端建立Socket連線,隨後的通訊就靠這個Socket連線了。

所以,伺服器會開啟固定埠(比如80)監聽,每來一個客戶端連線,就建立該Socket連線。由於伺服器會有大量來自客戶端的連線,所以,伺服器要能夠區分一個Socket連線是和哪個客戶端繫結的。一個Socket依賴4項:伺服器地址、伺服器埠、客戶端地址、客戶端埠來唯一確定一個Socket。

但是伺服器還需要同時響應多個客戶端的請求,所以,每個連線都需要一個新的程式或者新的執行緒來處理,否則,伺服器一次就只能服務一個客戶端了。

我們來編寫一個簡單的伺服器程式,它接收客戶端連線,把客戶端發過來的字串加上Hello再發回去。

首先,建立一個基於IPv4和TCP協議的Socket:

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

然後,我們要繫結監聽的地址和埠。伺服器可能有多塊網路卡,可以繫結到某一塊網路卡的IP地址上,也可以用0.0.0.0繫結到所有的網路地址,還可以用127.0.0.1繫結到本機地址。127.0.0.1是一個特殊的IP地址,表示本機地址,如果繫結到這個地址,客戶端必須同時在本機執行才能連線,也就是說,外部的計算機無法連線進來。

埠號需要預先指定。因為我們寫的這個服務不是標準服務,所以用9999這個埠號。請注意,小於1024的埠號必須要有管理員許可權才能繫結:

# 繫結埠:
s.bind(('127.0.0.1', 9999))

緊接著,呼叫listen()方法開始監聽埠,傳入的引數指定等待連線的最大數量:

s.listen(5)
print('Waiting for connection...')

接下來,伺服器程式通過一個死迴圈來接不斷接收來自客戶端的連線,accept()會等待並返回一個客戶端的連線:

while True:
    # 接受一個新連線:
    sock, addr = s.accept()
    # 建立新執行緒來處理TCP連線:
    t = threading.Thread(target=tcplink, args=(sock, addr))
    t.start()

每個連線都必須建立新執行緒(或程式)來處理,否則,單執行緒在處理連線的過程中,無法接受其他客戶端的連線:

def tcplink(sock, addr):
    print('Accept new connection from %s:%s...' % addr)
    sock.send(b'Welcome!')
    while True:
        data = sock.recv(1024)
        time.sleep(1)
        if not data or data.decode('utf-8') == 'exit':
            break
        sock.send(('Hello, %s!' % data.decode('utf-8')).encode('utf-8'))
    sock.close()
    print('Connection from %s:%s closed.' % addr)

連線建立後,伺服器首先發一條歡迎訊息,然後等待客戶端資料,並加上Hello再傳送給客戶端。如果客戶端傳送了exit字串,就直接關閉連線。

要測試這個伺服器程式,我們還需要單獨編寫一個客戶端程式:

##########
#測試 客戶端
##########
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# 建立連線:
s.connect(('127.0.0.1', 9999))
# 接收歡迎訊息:
print(s.recv(1024).decode('utf-8'))
for data in [b'Michael', b'Tracy', b'Sarah']:
    # 傳送資料:
    s.send(data)
    print(s.recv(1024).decode('utf-8'))
s.send(b'exit')
s.close()

測試時,先啟動伺服器程式,再執行客戶端程式,這樣我們就能完整看到這次簡要的建立連線-傳送請求-接受響應報文-解析並輸出的網路通訊過程了

完整程式碼

- server.py

########################
##########伺服器#########
########################


import socket#匯入socket庫
import time, threading#匯入threading模組

#建立一個給予IPv4和TCP協議的Socket:

s = socket.socket(socket.AF_INET , socket.SOCK_STREAM)

#監聽埠

#繫結
s.bind(('127.0.0.1',9999))
#呼叫listen()函式監聽埠,傳入的引數指定等待連線的最大數量
s.listen(5)
print('Waiting for connection...')

def tcplink(sock, addr):
    print('Accept new connection from %s:%s...' % addr)
    sock.send(b'Welcome!')
    while True:
        data = sock.recv(1024)
        time.sleep(1)
        if not data or data.decode('utf-8') == 'exit':
            break
        sock.send(('Hello, %s!' % data.decode('utf-8')).encode('utf-8'))
    sock.close()
    print('Connection from %s:%s closed.' % addr)

    #建立連線後,伺服器首先發一條歡迎訊息,然後等待客戶端資料,並加上Hello,xxx!再傳送給客戶端.入夥客戶端傳送了'exit'字串,就直接關閉連線


#伺服器通過一個死迴圈來接受來自客戶端的連結,accept()會等待並返回一個客戶端的連結:
while True:
    # 接受一個新連線:
    sock, addr = s.accept()
    # 建立新執行緒來處理TCP連線:
    t = threading.Thread(target=tcplink, args=(sock, addr))
    t.start()
#每個連結都必須建立新執行緒(或程式)來單獨處理,否則,單執行緒在處理連線的過程中,無法接受其他客戶的連結:

- client.py

##########
#測試 客戶端
##########
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# 建立連線:
s.connect(('127.0.0.1', 9999))
# 接收歡迎訊息:
print(s.recv(1024).decode('utf-8'))
for data in [b'Michael', b'Tracy', b'Sarah']:
    # 傳送資料:
    s.send(data)
    print(s.recv(1024).decode('utf-8'))
s.send(b'exit')
s.close()

執行結果:
這裡寫圖片描述

總結一下:

其實用TCP協議進行Socket程式設計在Python中十分簡單:

對於客戶端:

1.要主動連線伺服器的IP和指定埠,

對於伺服器

1.首先監聽指定埠
2.對每一個新的連線,建立一個執行緒或程式來處理。
3.通常,伺服器程式會無限執行下去。

注意:同一個埠,被一個Socket繫結了以後,就不能被別的Socket繫結了,即被佔用了,此時就會發生衝突

參考原始碼
do_tcp.py

相關文章