1. 程式人生 > >網路程式設計 三 基於TCP/IP協議簡單伺服器構建

網路程式設計 三 基於TCP/IP協議簡單伺服器構建

2018.0630**********************************************************************
author:wills

網路程式設計入門

計算機網路基礎

計算機網路是獨立自主的計算機互聯而成的系統的總稱,組建計算機網路最主要的目的是實現多臺計算機之間的通訊和資源共享。今天計算機網路中的裝置和計算機網路的使用者已經多得不可計數,而計算機網路也可以稱得上是一個“複雜巨系統”,對於這樣的系統有興趣的讀者可自行閱讀Andrew S.Tanenbaum老師的經典之作《計算機網路》或Kurose和Ross老師合著的《計算機網路:自頂向下方法》來了解計算機網路的相關知識。(前一本我正在看,內容真的把計算機網路寫的很透徹,就是需要一定的通訊相關知識,否則很多東西都感覺似是而非,後面那本沒看過,別人推薦的)

計算機網路發展史

  1. 1960s - 美國國防部ARPANET專案問世,奠定了分組交換網路的基礎。

  2. 1980s - 國際標準化組織(ISO)釋出OSI/RM,奠定了網路技術標準化的基礎。

  3. 1990s - 英國人蒂姆·伯納斯-李發明了圖形化的瀏覽器,瀏覽器的簡單易用性使得計算機網路迅速被普及。

TCP/IP模型

實現網路通訊的基礎是網路通訊協議,這些協議通常是由網際網路工程任務組 (IETF)制定的。所謂“協議”就是通訊計算機雙方必須共同遵從的一組約定,例如怎樣建立連線、怎樣互相識別等,網路協議的三要素是:語法、語義和時序。構成我們今天使用的Internet的基礎的是TCP/IP協議族,所謂協議族就是一系列的協議及其構成的通訊模型,我們通常也把這套東西稱為TCP/IP模型。與國際標準化組織釋出的OSI/RM這個七層模型不同,TCP/IP是一個四層模型,也就是說,該模型將我們使用的網路從邏輯上分解為四個層次,自底向上依次是:網路介面層、網路層、傳輸層和應用層

,如下圖所示。

這裡寫圖片描述

IP通常被翻譯為網際協議,它服務於網路層,主要實現了定址路由的功能。接入網路的每一臺主機都需要有自己的IP地址,IP地址就是主機在計算機網路上的身份標識。由於IPv4地址的數量有限,我們平常在家裡、辦公室以及其他可以接入網路的公共區域上網時獲得的IP地址並不是全球唯一的IP地址,而是一個區域網(LAN)中的內部IP地址,通過網路地址轉換(NAT)服務我們也可以實現對網路的訪問。計算機網路上有大量的被我們稱為“路由器”的網路中繼裝置,它們會儲存轉發我們傳送到網路上的資料分組,讓從源頭髮出的資料最終能夠找到傳送到目的地通路,這項功能就是所謂的路由。

TCP全稱傳輸控制協議,它是基於IP提供的定址和路由服務而建立起來的負責實現端到端可靠傳輸的協議,之所以將TCP稱為可靠的傳輸協議是因為TCP向呼叫者承諾了三件事情:

  1. 資料不傳丟不傳錯(利用握手、校驗和重傳機制可以實現)。
  2. 流量控制(通過滑動視窗匹配資料傳送者和接收者之間的傳輸速度)。
  3. 擁塞控制(通過RTT時間以及對滑動視窗的控制緩解網路擁堵)。

網路應用模式

  1. C/S模式和B/S模式。這裡的C指的是Client(客戶端),通常是一個需要安裝到某個宿主作業系統上的應用程式;而B指的是Browser(瀏覽器),它幾乎是所有圖形化作業系統都預設安裝了的一個應用軟體;通過C或B都可以實現對S(伺服器)的訪問。
  2. 去中心化的網路應用模式。不管是B/S還是C/S都需要伺服器的存在,伺服器就是整個應用模式的中心,而去中心化的網路應用通常沒有固定的伺服器或者固定的客戶端,所有應用的使用者既可以作為資源的提供者也可以作為資源的訪問者。

基於HTTP協議的網路資源訪問

HTTP(超文字傳輸協議)

HTTP是超文字傳輸協議(Hyper-Text Transfer Proctol)的簡稱,維基百科上對HTTP的解釋是:超文字傳輸協議是一種用於分散式、協作式和超媒體資訊系統的應用層協議,它是全球資訊網資料通訊的基礎,設計HTTP最初的目的是為了提供一種釋出和接收HTML頁面的方法,通過HTTP或者HTTPS(超文字傳輸安全協議)請求的資源由URI(統一資源識別符號)來標識。簡單的說,通過HTTP我們可以獲取網路上的(基於字元的)資源,開發中經常會用到的網路API(有的地方也稱之為網路資料介面)就是基於HTTP來實現資料傳輸的。

基於傳輸層協議的套接字程式設計

套接字這個詞對很多不瞭解網路程式設計的人來說顯得非常晦澀和陌生,其實說得通俗點,套接字就是一套用C語言寫成的應用程式開發庫,主要用於實現程序間通訊和網路程式設計,在網路應用開發中被廣泛使用。在Python中也可以基於套接字來使用傳輸層提供的傳輸服務,並基於此開發自己的網路應用。實際開發中使用的套接字可以分為三類:流(TCP)、資料報(UDP)和原始套接字。

TCP套接字

所謂TCP套接字就是使用TCP協議提供的傳輸服務來實現網路通訊的程式設計介面。在Python中可以通過建立socket物件並指定type屬性為SOCK_STREAM來使用TCP套接字。由於一臺主機可能擁有多個IP地址,而且很有可能會配置多個不同的服務,所以作為伺服器端的程式,需要在建立套接字物件後將其繫結到指定的IP地址和埠上。這裡的埠並不是物理裝置而是對IP地址的擴充套件,用於區分不同的服務例如我們通常將HTTP服務跟80埠繫結,而MySQL資料庫服務預設繫結在3306埠,這樣當伺服器收到使用者請求時就可以根據埠號來確定到底使用者請求的是HTTP伺服器還是資料庫伺服器提供的服務。埠的取值範圍是0~65535,而1024以下的埠我們通常稱之為“著名埠”(留給像FTP、HTTP、SMTP等“著名服務”使用的埠,有的地方也稱之為“周知埠”),自定義的服務通常不使用這些埠,除非自定義的是HTTP或FTP這樣的著名服務。

下面的程式碼實現了一個提供時間日期的伺服器。

from socket import socket, SOCK_STREAM, AF_INET
from datetime import datetime


def main():
    # 1 . 建立套接字物件並指定使用哪種傳輸服務TCP, UDP
    # family = AF_INET  - IPV4地址
    # family = AF_INET6 - IPV6地址
    # type = SOCK_DRGAM - UDP 套接字
    # type = SOCK_STREAM - TCP 套接字
    # type = SOCK_RAM - 原始套接字
    server = socket() # 預設使用IPv4 以及 TCP套接字

    # 2. 繫結IP地址和埠 (埠是IP地址的擴充套件,用於區分不同的服務)
    # 同一時間在同一個埠只能繫結一個服務,否則報錯
    server.bind(('10.7.189.88', 12321))

    # 3. 開啟監聽,監聽客戶端是否連線到伺服器
    # 512 可以理解為監聽連線的佇列大小
    server.listen(512)
    print('伺服器已啟動...開始監聽12321埠')

    while True:
        # 4. 通過無限迴圈, 接收客戶端的連線並作出相應的處理(就是提供的什麼服務)
        # accept方法是一個阻塞方法,如果沒有客戶端連線到伺服器,程式碼不會向下執行
        # accept方法返回一個tuple, 其中第一個元素表示客戶端物件
        # 第二個元素是連線伺服器的客戶端的地址(由IP 和 埠組成)
        client, addr = server.accept()
        print(str(addr) + '連線到了伺服器')

        # 5. 處理,提供服務,這裡的服務是傳送資料
        # 這裡傳送的資料只能是二進位制的位元組資料,需要encode轉碼
        # 一般都推薦使用 utf-8 但是如果是Windows客戶端,最好還是用gbk,因為Windows預設接收資料編碼是gbk
        client.send(('當前時間是北京時間: ' + str(datetime.now())).encode('gbk'))

        # 6. 服務完成,關閉連線,等待下一個客戶端連線進來
        client.close()


if __name__ == '__main__':
    main()

執行伺服器程式後我們可以通過Windows系統的telnet來訪問該伺服器,結果如下圖所示。

telnet 10.7.189.88 12321

這裡寫圖片描述

當然我們也可以通過Python的程式來實現TCP客戶端的功能,相較於實現伺服器程式,實現客戶端程式就簡單多了,程式碼如下所示。

from socket import socket 


def main():
    # 1. 建立套接字物件, 預設使用 TCP 與 IPv4 協議
    client = socket()

    # 2. 連線到伺服器(需要指定IP地址以及埠)
    client.connect(('10.7.189.88', 12321))

    # 3. 從伺服器接收資料(接收的資料一般是二進位制的,需要解碼)
    # 解碼的標準要與伺服器傳輸的編碼相同
    recv = client.recv(1024).decode('gbk')

    # 4. 關閉連線
    client.close()

    print(recv) # 將獲取的結果打印出來


if __name__ == '__main__':
    main()

需要注意的是,上面的伺服器並沒有使用多執行緒或者非同步I/O的處理方式,這也就意味著當伺服器與一個客戶端處於通訊狀態時,其他的客戶端只能排隊等待。很顯然,這樣的伺服器並不能滿足我們的需求,我們需要的伺服器是能夠同時接納和處理多個使用者請求的。下面我們來設計一個簡易聊天室,使用多執行緒技術處理多個使用者請求的伺服器,該伺服器會將當前客戶端傳送的資訊都發送給每一個聊天室中的使用者,從而使得聊天室中的使用者可以看見其它人傳送的資訊.

伺服器端程式碼:

from socket import socket, SOCK_STREAM
from threading import Thread


def main():
    # 建立執行緒類,用來處理客戶端的請求
    class ServerThread(Thread):

        def __init__(self, cur_client):
            super().__init__()
            self.cur_client = cur_client

        def run(self):
            while True:
                try:
                    # 獲取當前連線的客戶端傳送的 msg
                    msg = self.cur_client.recv(1024)
                    for cli in clients:
                        # 伺服器把 msg 發給所有的客戶端(聊天室)
                        cli.send(msg)
                    print('當前聊天室人數: %d人' % len(clients))

                    if msg.decode('utf-8').split(' ')[-1] == 'bye':  # 判斷當前玩家是否下線了
                        clients.remove(self.cur_client)              # 如果下線,則將玩家踢出
                        self.cur_client.close()

                except Exception as e:
                    clients.remove(self.cur_client)
                    self.cur_client.close()

    server = socket(type=SOCK_STREAM)
    server.bind(('10.7.189.88', 11111))
    server.listen(512)
    print('伺服器已經啟動,正在監聽...')

    # 將連線的客戶端放入到一個列表中管理
    # 開啟多執行緒, 主執行緒只負責接收新增的客戶端連線並且放入到 clients 裡面,再開啟一個子執行緒處理,然後繼續回到監聽狀態
    # 當client 斷開連線只需要將其踢出 clients即可
    clients = []

    while True:
        try:
            client, addr = server.accept()
            print('%s加入聊天室' % str(addr))
            clients.append(client)
            ServerThread(client).start()
        except Exception as e:
            print(e)


if __name__ == '__main__':
    main()

客戶端的程式碼

from socket import socket, SOCK_STREAM
from time import sleep
from threading import Thread


# 建立一個用於接收聊天資訊,並且顯示出來的類
class RecvThread(Thread):

    def __init__(self, client):
        super().__init__()
        self.client = client

    def run(self):
        while True:
            # 接收伺服器傳送過來的資料,單條資料最大接收量 1024
            # 這些資料就是別人發出的資訊,包括自己發出去的
            data = self.client.recv(1024)
            # 接收到的資料是二進位制資料,需要解碼
            data = data.decode('utf-8')
            if len(data) > 0:
                print(data)


def main():
    # 主執行緒負責輸入資訊, 子執行緒負責將所有人的發言顯示出來
    nickname = input('請輸入暱稱:')              # 使用者的暱稱
    myclient = socket(type=SOCK_STREAM)          # 基於TCP 協議
    myclient.connect(('10.7.189.88', 11111))     # 需要連線的伺服器的 IP 和埠

    # 子執行緒 - 接收文字執行緒,設定為守護執行緒, 主執行緒結束之後也隨之結束
    recv = RecvThread(myclient)
    # 設定守護執行緒  daemon 一定要寫在 start()方法前面
    recv.daemon = True
    recv.start()

    # 傳送文字執行緒, 也是主執行緒
    while True:
        # 判斷輸入文字是否為空
        while True:
            msg = input('')
            if len(msg) > 0:
                break

        # 拼接需要傳送的文字 , 可以用 + 和 .join()方法 處理大量拼接的時候用 join(),
        # 但是在拼接數量很少的時候, 還是 + 的效率高一點
        # content = ''.join([nickname, ': ', msg])
        content = nickname + ': ' + msg
        myclient.send(content.encode('utf-8'))
        sleep(0.001)
        if msg == 'bye':
            # 客戶端斷開連線
            myclient.close()
            break


if __name__ == '__main__':
    main()

說明:上面的程式碼主要為了講解網路程式設計的相關內容因此,只對必要的地方異常狀況進行處理,可以新增異常處理程式碼(try – except)來增強程式的健壯性。

UDP套接字

傳輸層除了有可靠的傳輸協議TCP之外,還有一種非常輕便的傳輸協議叫做使用者資料報協議,簡稱UDP。TCP和UDP都是提供端到端傳輸服務的協議,二者的差別就如同打電話和發簡訊的區別,後者不對傳輸的可靠性和可達性做出任何承諾從而避免了TCP中握手和重傳的開銷,所以在強調效能和而不是資料完整性的場景中(例如傳輸網路視訊資料,比如直播),UDP可能是更好的選擇。不知大家有沒有注意到,在觀看網路視訊時,有時會出現卡頓,有時會出現花屏,這無非就是部分資料傳丟或傳錯造成的。在Python中也可以使用UDP套接字來建立網路應用,我用傳輸一張圖片進行演示.

圖片接收端程式碼

from socket import socket, SOCK_DGRAM


def main():
    # 1 . 建立套接字物件並指定使用哪種傳輸服務 UDP
    server = socket(type=SOCK_DGRAM)
    # 使用 UDP協議 節約了握手,驗證,重傳等等步驟,提高了效率,但是不能保證資料不傳丟,不傳錯

    # 2. 繫結IP地址和埠 (埠是IP地址的擴充套件,用於區分不同的服務)
    # 同一時間在同一個埠只能繫結一個服務,否則報錯
    server.bind(('10.7.189.88', 12321))

    print('伺服器已啟動...開始監聽12321埠')

    # 因為要接收的是一張圖片,它是二進位制的資料流
    data = bytes()
    while True:
        # 3. 通過無限迴圈, 接收所有的資料
        # recvfrom方法返回一個tuple, 其中第一個元素表示收到的二進位制資料
        # 第二個元素是連線伺服器的客戶端的地址(由IP 和 埠組成)
        # 1024 表示每次接收 1024 位元組的資料
        seg, addr = server.recvfrom(1024)
        print(str(addr) + '連線到了伺服器')
        data += seg
        # 如果獲取的資料長度以經 >= 圖片總資料長度,終止迴圈
        # if len(data) >= 77098: 但是通常情況是伺服器並不知道上傳的圖片,或者檔案的大小
        # 所以最好的辦法是客戶端傳入一個標誌,表示傳輸結束
        if len(seg) == 0:
            break

    # 4. 將接收的圖片資料儲存為一張圖片
    with open('recv_mm.jpg', 'wb') as f:
        f.write(data)
    print('接收成功')


if __name__ == '__main__':
    main()

圖片傳送到程式碼

# 傳送一張照片到別人的電腦上 基於UDP協議的資料傳輸
from socket import socket, SOCK_DGRAM
from time import sleep


def main():
    sender = socket(type=SOCK_DGRAM)

    # 讀取將要傳輸的圖片資料
    with open('hello.jpg', 'rb') as f:
        # 將讀取到的圖片資料放入到data變數中
        data = f.read()
        # 獲取圖片的長度
        data_len = len(data)
        # 圖片資料表較大,一般不能直接傳完需要分成1024位元組一次次傳輸
        total = 0

        while total < data_len:

            # 每次傳輸 1024位元組的資料
            sender.sendto(data[total:total + 1024], ('10.7.189.88', 12321))
            total += 1024
            # 為了避免計算機執行太快,但是網路速度一般, 我們人為給每次傳送設定一定的延遲
            # 這個延遲很小很小就可以
            sleep(0.000001)
    # 傳輸完成後再傳輸一個結束標誌
    sender.sendto(bytes(), ('10.7.189.88', 12321))
    # 傳輸完成主動斷開連線
    sender.close()


if __name__ == '__main__':
    main()

以上內容就是網路程式設計的相關基礎知識,至此向我的老師 駱昊博士致以最崇高的敬意,這些都是駱老師教授給我的知識. 謝謝老師