1. 程式人生 > >Python基礎28_socket.粘包

Python基礎28_socket.粘包

一. tcp下的socket
    1. 正常的tcp下的socket
    (1). server端:
    import socket
    server = socket.socket()
    socket.bind(("127.0.0.1", 8001))        # 把地址和埠繫結到套接字
    socket.listen        # 監聽連線
    conn, addr = server.accept()        # 接受客戶端連線
    from_client_msg = conn.recv(1024)        # 接受客戶端資訊
    print(from_client_msg)    
    conn.send(b"hi")        # 向客戶端傳送資訊
    conn.close()        # 關閉客戶端套接字
    server.close()        # 關閉服務端套接字
    (2). client端:
    import socket
    client = socket.socket()        # 建立客戶端套接字
    client.connect(("127.0.0.1", 8001))        # 嘗試連線伺服器
    client.send(b"hello")        # 向服務端傳送訊息
    from_server_msg = client.recv(1024)        # 接收服務端訊息
    print(from_server_msg)
    client.close()        # 關閉客戶端套接字
    2. socket繫結ip和埠時可能會出現下面的訊息:
    OSError: [Error 48] Address already in use    通常每個套接字地址(協議/網路地址/埠)只允許使用一次,  
    (即一個ip地址和埠在同一個模組下只允許開啟一次, 但是在不同的模組下是可以使用同一個地址的)
    解決方法: 
    # 加入一條socket配置, 重用ip和埠
    import socket
    from socket import SOL_SOCKET, SO_REUSEADDR
    
    server = socket.socket()
    server.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)
    server.bind(("127.0.0.1", 8900))
    server.listen()
    conn, addr = server.accept()
    from_client_msg = conn.recv(1024)
    print(from_client_msg)
    conn.send(b"hi")
    conn.close()
    server.close()
    注意一點: 用socket進行通訊, 必須是一收一發對應好
    3. tcp的長連線, 如何優雅的斷開
    (1). 服務端:
    import socket
    server = socket.socket()
    server.bind(("127.0.0.1", 8001))
    server.listen()
    conn, addr = server.accept()
    while 1:
        from_client_msg = conn.recv(1024)
        ret = from_client_msg.decode("utf-8")
        print("客戶端說: %s" % ret)
        if ret == "bye":
            break
        to_client_msg = input("請輸入想對客戶端說的話: ")
        conn.send(to_client_msg.encode("utf-8"))
        if to_client_msg == "bye":
            break
    conn.close()
    server.close()
    (2). 客戶端:
    import socket
    client = socket.socket()
    client.connect(("127.0.0.1", 8001))
    while 1:
        to_server_msg = input("請輸入想對服務端說的話: ")
        client.send(to_server_msg.encode("utf-8"))
        if to_server_msg == "bye":
            break
        from_sever_msg = client.recv(1024)
        ret = from_sever_msg.decode("utf-8")
        print("服務端說: %s" % ret)
        if ret == "bye":
            break
    client.close()
    開啟一個服務端, 開啟兩個客戶端, 你會發現, 第一個客戶端可以和服務端收發訊息, 但是第二個連線的客戶端發訊息服務端是收不到的
    原因是: tcp屬於長連線, 就是一直佔用著這個通道, 由於一直處於佔線, 其他的客戶端只能等待連線, 除非斷開了連線(可以優雅的斷開, 如果是強制斷開就會報錯, 因為服務端的程式還在第一個迴圈裡面)
    強制斷開: ConnectionResetError: [WinError 10054] 遠端主機強迫關閉了一個現有的連線。
    如何優雅的斷開?
    (1). 服務端:
    import socket
    server = socket.socket()
    server.bind(("127.0.0.1", 8001))
    server.listen()
    while 1:
        conn, addr = server.accept()
        while 1:
            from_client_msg = conn.recv(1024)
            ret = from_client_msg.decode("utf-8")
            print("客戶端說: %s" % ret)
            if ret == "bye":
                break
            to_client_msg = input("請輸入想對客戶端說的話: ")
            conn.send(to_client_msg.encode("utf-8"))
            if to_client_msg == "bye":
                break
        conn.close()
    server.close()
二. udp下的socket
    先從伺服器端說起。伺服器端先初始化Socket,然後與埠繫結(bind),recvform接收訊息,這個訊息有兩項,訊息內容和對方客戶端的地址,然後回覆訊息時也要帶著你收到的這個客戶端的地址,傳送回去,最後關閉連線,一次互動結束
    1. 正常的udp下的socket
    (1). server端
    import socket
    # 建立一個udp服務端的套接字 STREAM(流, 即tcp) DGRAM(資料報, 即udp)
    udp_server = socket.socket(type = socket.SOCK_DGRAM)
    udp_server.bind(("127.0.0.1", 8001))
    msg, addr = udp_server.recvfrom(1024)
    print(msg.decode("utf-8"))
    udp_server.sendto(b"hi", addr)
    udp_server.close()
    (2). client端
    import socket
    ip_port = ("127.0.0.1", 8001)
    udp_client = socket.socket(type = socket.SOCK_DGRAM)
    udp_client.sendto(b"helli", ip_port)
    back_msg, addr = udp_client.recvfrom(1024)
    print(back_msg.decode("utf-8"), addr)
    2. 類似qq聊天程式碼實現: 
    (1). 服務端:
    import socket
    ip_port = ("127.0.0.1", 8081)
    udp_server = socket.socket(socket.AF_INET ,socket.SOCK_DGRAM)
    udp_server.bind(ip_port)
    while 1:
        qq_msg, addr = udp_server.recvfrom(1024)        # 阻塞狀態, 等待接收訊息
        print("來自[%s:%s]的一條訊息: %s" % (addr[0], addr[1], qq_msg.decode("utf-8")))
        back_msg = input("請輸入要回復的訊息: ")
        udp_server.sendto(back_msg.encode("utf-8"), addr)
    (2). 客戶端:
    import socket
    BUFFSIZE = 1024
    udp_client = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    qq_name_dic = {
        "taibai": ("127.0.0.1", 8081),
        "alex": ("127.0.0.1", 8081),
        "wusir": ("127.0.0.1", 8081),
        "nvshen": ("127.0.0.1", 8081)
    }
    while 1:
        print("聊天物件有:")
        for k in qq_name_dic:
            print("\t%s" % k)
        qq_name = input("請選擇聊天物件:").strip()
        while 1:
            msg = input("請輸入訊息, 回車傳送, 輸入Q結束聊天: ").strip()
            if msg.upper() == "Q":
                break
            if not msg or not qq_name or qq_name not in qq_name_dic:
                continue
            udp_client.sendto(msg.encode("utf-8"), qq_name_dic[qq_name])
            back_msg, addr = udp_client.recvfrom(BUFFSIZE)
            print("來自[%s:%s]的一條訊息: %s" % (addr[0], addr[1], back_msg.decode("utf-8")))
三. socket型別和方法:
    1. socket型別:
    socket.AF_UNIX: 只能夠用於單一的unix系統間的通訊
    socket.AF_INET: 伺服器之間網路通訊, ipv4
    socket.AT_INET6: ipv6
    socket.SOCK_STREAM: 流式socket, for tcp
    socket.SOCK_DGRAM: 資料報式socket, for udp
    socket.SOCK_RAW: 原始套接字, 普通的套接字無法處理icmp, igmp等網路報文, 而socket_raw可以, 其次, SOCK_RAW也可以處理特殊的ipv4報文, 此外, 利用原始套接字, 可以通過IP_HDRINCL套接字選項由使用者構造ip頭
    socket.SOCK_SEQPACKET: 可靠地連續資料報服務
    建立TCP Socket: s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    建立UDP Socket: s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    2. socket方法: 
    (1). 服務端方法:
    s.bind(addr): 將套接字繫結到地址, 在AF_INET下, 以元祖的形式表示地址
    s.listen(backlog): 開始監聽tcp連線, backlog指定在拒絕連線之前, 作業系統可以掛起的最大連線數量, 該值至少為1, 大部分應用程式設定為5就可以了
    s.accept(): 接受tcp連線並返回(conn, addr), 其中conn是新的套接字物件, 可以用來接收和傳送資料, addr是連線客戶端的地址
    (2). 客戶端方法:
    s.connect(addr): 連線到addr處的套接字, 一般addr的格式是元祖, 如果連接出錯, 返回socket.errer錯誤
    s.connect_ex(addr): 功能與connect()相同, 但是成功返回(), 失敗返回error的值
    (3). 公共socket方法:
    s.recv(bufsize[, flag]): 接受tcp套接字的資料, 資料以字串形式返回, bufsize指定要接收的最大資料量, flag提供有關訊息的其他資訊, 通常可以忽略
    s.send(string[, flag]): 傳送tcp資料, 將string中的資料傳送到連線的套接字, 返回值是要傳送的直接數量, 該數量可能小於string的位元組大小
    s.sendall(string[, flag]): 完整發送tcp資料, 將string中的資料傳送到連線的套接字, 但是在返回之前會嘗試傳送所有的資料, 成功返回NONE, 失敗則丟擲異常
    s.recvfrom(bufsize[, flag]): 接收udp套接字的資料, 與recv()類似, 但返回值是(data, addr), 其中data是包含接收資料的字串, addr是傳送資料的套接字地址
    s.sendto(string[, flag], addr): 傳送udp資料, 將資料傳送到套接字, addr是形式為(ipaddr, port)元祖, 指定遠端地址, 返回值是傳送的位元組數
    s.close(): 關閉套接字
    s.getpeername(): 返回套接字的遠端地址, 返回值通常是元祖
    s.getsockname(): 返回套接字自己的地址, 通常是一個元祖     
    s.setsockopt(level, optname, value): 設定給定套接字選項的值
    s.getsockopt(level, optname[.buflen]): 返回套接字選項的值
    s.settimeout(timeout): 設定套接字操作的超時期, timeout是一個浮點數, 單位是秒, 值為None表示沒有超時期, 一般, 超時期應該在剛建立套接字時設定, 因為他們可能用於連線的操作
    s.gettimeout(): 返回當前超時期的值, 單位是秒, 如果沒有設定超時期, 則返回None
    s.fileno(): 返回套接字的檔案描述符
    s.setblocking(flag): 如果flag為0, 則將套接字設定為非阻塞模式, 否則將套接字設為阻塞模式(預設值), 非阻塞模式下, 如果呼叫recv()沒有發現任何資料, 或send()呼叫無法立即傳送資料, 那麼將引起socket.error異常
    s.makefile(): 建立一個與該套接字相關聯的檔案  
四. 粘包
    1. 緩衝區
    每個socket被建立後, 都會分配兩個緩衝區, 輸入緩衝區和輸出緩衝區
    write()/seng() 並不立即向網路中傳輸資料, 而是先將資料寫入緩衝區, 再由tcp協議將資料從緩衝區傳送到目標機器, 一旦將資料寫入到緩衝區, 函式就可以成功返回, 不管他們有沒有到大目標機器, 也不管他們何時被髮送到網路,這些都是tcp協議負責的事情
    tcp協議獨立於write()/send()函式, 資料有可能剛被寫入緩衝區就傳送到網路, 也可能在緩衝區中不斷積壓, 多次寫入的資料被一次性發送到網路, 這取決於當時的網路情況, 當前執行緒是夠空閒等諸多因素, 不由程式眼控制
    read()/recv() 函式意思如此, 也從緩衝區讀取資料, 而不是直接從網路中獲取
    這些I/O緩衝區特性可整理如下:
    (1). I/O緩衝區在每個tcp套接字中單獨存在
    (2). I/O緩衝區在建立套接字時自動生成
    (3). 即使關閉套接字也會繼續傳送輸出緩衝區中遺留的資料
    (4). 關閉套接字將丟失輸入緩衝區中的資料
    輸入輸出緩衝區額預設大小一般都是8k, 可以通過getsockopt() 函式獲取: 
    https://www.cnblogs.com/ouyangyixuan/p/5894542.html
    import socket
    server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    bsize = server.getsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF)
    print(bsize)
    # 65536
    2. windows下cmd視窗呼叫系統指令
    在cmd命令列輸入dir(檢視當前資料夾下的所有檔案和資料夾)和ipconfig(檢視當前電腦的網路資訊)
    藉助系統指令和指令輸出的結果來模擬一下粘包現象
    3. 粘包現象(兩種)
    MTU(Maximum Transmission Unit): 意思是網上傳輸的最大資料包, MTU的單位是位元組, 大部分網路裝置的MTU都是1500個位元組, 也就是1500KB, 如果本機一次需要傳送的資料比閘道器的MTU大, 打的資料包就會被拆開來傳送, 這樣會產生很多資料包碎片, 增加丟包率, 降低網路速度
    超出緩衝區大小會報錯, 或者udp協議的時候, 你的一個數據包的大小超過了你一次recv能接受的大小, 也會報錯, tcp不會, 但是超出緩衝區大小的時候, 肯定會報錯
    OSError: [WinError 10040] 一個在資料包套接字上傳送的訊息大於內部訊息緩衝區或其他一些網路限制, 或該使用者用於接收資料報的緩衝區比資料報小
    (1). 接收方沒有及時接受緩衝區的包, 造成多個包接收 (客戶端傳送了一段資料, 服務端只收了一小部分, 服務端下次再收的時候還是從緩衝區拿上次遺留的資料, 產生粘包)
    (2). 傳送資料時間間隔很短, 資料也很小, 會合在一起, 產生粘包
    4. 模擬粘包現象
    subprocess模組
    import subprocess
    cmd = input("請輸入指令>>>")
    ret = subprocess.Popen(
        cmd,                            # 字串指令, "dir", "ipconfig"
        shell = True,                    # 使用shell就相當於使用cmd視窗
        stderr = subprocess.PIPE,        # 標準錯誤輸出, 拿到錯誤指令的報錯資訊
        stdout = subprocess.PIPE,        # 標準輸出, 拿到正確指令的輸出結果
    )    
    print(ret.stdout.read().decode("gbk"))
    print(ret.stderr.read().decode("gbk"))
    注意: 如果是windows, 那麼ret.stdout.read()讀出的就是gbk編碼的 在接收端需要用gbk解碼且只能從管道里讀一次結果, PIPE是管道
    (1). tcp粘包演示一:
    服務端:
    from socket import *
    import subprocess
    ip_port = ("127.0.0.1", 8001)
    BUFSIZE = 1024
    server = socket(AF_INET, SOCK_STREAM)
    server.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)
    server.bind(ip_port)
    server.listen(5)
    while 1:
        conn, addr = server.accept()
        print("客戶端>>>", addr)
        while 1:
            cmd = conn.recv(BUFSIZE)
            if len(cmd) == 0:
                break
            ret = subprocess.Popen(
                cmd.decode("utf-8"),
                shell = True,
                stdout = subprocess.PIPE,
                stdin = subprocess.PIPE,
                stderr =subprocess.PIPE
            )
            stderr = ret.stderr.read()
            stdout = ret.stdout.read()
            conn.send(stderr)
            conn.send(stdout)
    客戶端: 
    import socket
    ip_port = ("127.0.0.1", 8001)
    size = 1024
    client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    client.connect(ip_port)
    while 1:
        msg = input("請輸入要執行的指令, Q退出: ").strip()
        if len(msg) == 0:
            continue
        if msg.upper() == "Q":
            break
        client.send(msg.encode("utf-8"))
        from_server_msg = client.recv(1025)
        print("接收的返回結果長度為: " , len(from_server_msg))
        # windows返回的內容需要用gbk來解碼, 因為windows系統的預設編碼是gbk
        print(from_server_msg.decode("gbk"))
    (2). tcp粘包演示二:
    服務端:
    import socket
    server = socket.socket()
    server.bind(("127.0.0.1", 8001))
    server.listen(5)
    conn, addr = server.accept()
    from_client_msg1 = conn.recv(1024)
    from_client_msg2 = conn.recv(1024)
    print(from_client_msg1.decode("utf-8"))
    print(from_client_msg2.decode("utf-8"))
    conn.close()
    server.close()
    客戶端:
    import socket
    client = socket.socket()
    client.connect(("127.0.0.1", 8001))
    client.send("hello".encode("utf-8"))
    client.send("hi".encode("utf-8"))
    client.close()
    (3). udp是面向包的,所以udp不存在粘包
    因為udp是面向報文的, 意思是每個訊息是一個包, 接收端設定接收大小的時候, 必須要比你發的這個包大, 不然一次接收不了就會報錯, 而tcp不會報錯, 這也是為什麼udp會丟包的原因
    傳送端可以是一K一K地傳送資料,而接收端的應用程式可以兩K兩K地提走資料,當然也有可能一次提走3K或6K資料,或者一次只提走幾個位元組的資料,也就是說,應用程式所看到的資料是一個整體,或說是一個流(stream),一條訊息有多少位元組對應用程式是不可見的,因此TCP協議是面向流的協議,這也是容易出現粘包問題的原因。而UDP是面向訊息的協議,每個UDP段都是一條訊息,應用程式必須以訊息為單位提取資料,不能一次提取任意位元組的資料,這一點和TCP是很不同的。怎樣定義訊息呢?可以認為對方一次性write/send的資料為一個訊息,需要明白的是當對方send一條資訊的時候,無論底層怎樣分段分片,TCP協議層會把構成整條訊息的資料段排序完成後才呈現在核心緩衝區。
    例如基於tcp的套接字客戶端往服務端上傳檔案,傳送時檔案內容是按照一段一段的位元組流傳送的,在接收方看了,根本不知道該檔案的位元組流從何處開始,在何處結束
    所謂粘包問題主要還是因為接收方不知道訊息之間的界限,不知道一次性提取多少位元組的資料所造成的。
    此外,傳送方引起的粘包是由TCP協議本身造成的,TCP為提高傳輸效率,傳送方往往要收集到足夠多的資料後才傳送一個TCP段。若連續幾次需要send的資料都很少,通常TCP會根據優化演算法把這些資料合成一個TCP段後一次傳送出去,這樣接收方就收到了粘包資料。
        1.TCP(transport control protocol,傳輸控制協議)是面向連線的,面向流的,提供高可靠性服務。收發兩端(客戶端和伺服器端)都要有一一成對的socket,因此,傳送端為了將多個發往接收端的包,更有效的發到對方,使用了優化方法(Nagle演算法),將多次間隔較小且資料量小的資料,合併成一個大的資料塊,然後進行封包。這樣,接收端,就難於分辨出來了,必須提供科學的拆包機制。 即面向流的通訊是無訊息保護邊界的。
        2.UDP(user datagram protocol,使用者資料報協議)是無連線的,面向訊息的,提供高效率服務。不會使用塊的合併優化演算法,, 由於UDP支援的是一對多的模式,所以接收端的skbuff(套接字緩衝區)採用了鏈式結構來記錄每一個到達的UDP包,在每個UDP包中就有了訊息頭(訊息來源地址,埠等資訊),這樣,對於接收端來說,就容易進行區分處理了。 即面向訊息的通訊是有訊息保護邊界的。
        3.tcp是基於資料流的,於是收發的訊息不能為空,這就需要在客戶端和服務端都新增空訊息的處理機制,防止程式卡住,而udp是基於資料報的,即便是你輸入的是空內容(直接回車),那也不是空訊息,udp協議會幫你封裝上訊息頭,實驗略
    udp的recvfrom是阻塞的,一個recvfrom(x)必須對唯一一個sendinto(y),收完了x個位元組的資料就算完成,若是y>x資料就丟失,這意味著udp根本不會粘包,但是會丟資料,不可靠
    tcp的協議資料不會丟,沒有收完包,下次接收,會繼續上次繼續接收,己端總是在收到ack時才會清除緩衝區內容。資料是可靠的,但是會粘包。
五. 粘包的解決方案
    粘包的原因: 接收方不知道訊息之間的界限, 不知道一次提取多少個位元組所造成的
    1. 解決方案一: 接收端不知道傳送端將要傳送的位元組流的長度, 所以解決粘包的方法就是圍繞, 如何讓傳送的端在傳送資料前, 把自己將要傳送的位元組流總大小讓接收端知曉, 然後接收端發一個確認訊息給傳送端, 傳送端再發送過來後面真是的內容, 接收端再來一個死迴圈接收完所有的資料.
    (1). 服務端:
    import socket
    import subprocess
    server = socket.socket()
    server.bind(("127.0.0.1", 8001))
    server.listen(5)
    conn, addr = server.accept()
    print("客戶端地址:", addr)
    while 1:
        # 接收客戶端的指令
        client_inst = conn.recv(1024)
        if client_inst.decode("utf-8").upper() == "Q":
            break
        res = subprocess.Popen(
            client_inst.decode("utf-8"),
            shell = True,
            stderr = subprocess.PIPE,
            stdout = subprocess.PIPE
        )
        err = res.stderr.read()
        if err:
            ret = err
        else:
            ret = res.stdout.read()
        data_length = len(ret)
        # 給客戶端傳送指令結果的長度
        conn.send(str(data_length).encode("utf-8"))
        # 接收客戶端的回覆
        resp = conn.recv(1024)
        if resp.decode("utf-8") == "ok":
            # 給客戶端傳送指令結果
            conn.sendall(ret)
    conn.close()
    server.close()
    (2). 客戶端:
    import socket
    client = socket.socket()
    client.connect(("127.0.0.1", 8001))
    while 1:
        inst = input("請輸入要執行的操作指令,Q退出:").strip()
        if inst.upper() == "Q":
            client.send(inst.encode("utf-8"))
            break
        else:
            # 給服務端傳送指令
            client.send(inst.encode("utf-8"))
            # 接收服務端發來的指令結果長度
            data_l = client.recv(1024)
            data_length = int(data_l.decode("utf-8"))
            print("操作指令結果的長度為: ", data_length)
            # 向服務端傳送確認資訊
            client.send("ok".encode("utf-8"))
            # 接收服務端發來的指令結果
            data = client.recv(data_length)
            print("操作指令的結果為: \n", data.decode("gbk"))
    client.close()
    2. 解決方案二: 
    通過struct模組將需要傳送的內容的長度進行打包, 打包成一個4個位元組長度的資料傳送到客戶端, 客戶端只要取出前4個位元組, 然後對這4個位元組的資料盡進行解包, 根據拿到的長度來繼續接受實際傳送資料的長度
    struct模組的作用是對python基本型別值與用python字串格式表示的C struct型別間的轉換
    這裡主要用到struct模組中的兩個函式:
    pack(): 將num轉換成二進位制
        bytes = struct.pack("i", num)
    unpack(): 將bytes轉換成int型別, 返回結果是元祖
        a, = struct.unpack("i", bytes)