1. 程式人生 > >Python之路(第三十一篇) 網路程式設計:簡單的tcp套接字通訊、粘包現象

Python之路(第三十一篇) 網路程式設計:簡單的tcp套接字通訊、粘包現象

 

一、簡單的tcp套接字通訊

套接字通訊的一般流程

服務端

  server = socket() #建立伺服器套接字
  server.bind()      #把地址繫結到套接字,網路地址加埠
  server.listen()      #監聽連結
  inf_loop:      #伺服器無限迴圈
      conn,addr = server.accept() #接受客戶端連結,建立連結conn
      conn_loop:         #通訊迴圈
          conn.recv()/conn.send() #通過建立的連結conn不斷的對話(接收與傳送訊息)
      conn.close()    #關閉客戶端套接字連結conn
  server.close()        #關閉伺服器套接字(可選)

  

客戶端

  
  client = socket()    # 建立客戶套接字
  client.connect()    # 嘗試連線伺服器,用ip+port
  comm_loop:        # 通訊迴圈
       client.send()/client.recv()    # 對話(傳送/接收)訊息
  client.close()            # 關閉客戶套接字

  

套接字通訊例子

socket通訊流程與打電話流程類似,我們就以打電話為例實現簡單的tcp套接字通訊

服務端

  
  import socket
  ​
  # 1.買手機
  phone = socket.socket(socket.AF_INET,socket.SOCK_STREAM)  # 基於網路通訊的 基於tcp通訊的套接字
  ​
  # 2.繫結手機卡(IP地址) 執行這個軟體的電腦IP地址  ip和埠都應該寫到配置檔案中
  phone.bind(('127.0.0.1',8080)) # 埠0-65535   0-1024 給作業系統,127.0.0.1是本機地址即本機之間互相通訊
  ​
  # 3.開機
  phone.listen(5) # 5 代表最大掛起的連結數
  ​
  # 4.等電話連結
  print('伺服器執行啦...')
  # res = phone.accept()  #底層 就是 tcp 三次握手
  # print(res)
  conn,client_addr = phone.accept()  # conn 電話線  拿到可以收發訊息的管道  conn連結
  ​
  while True:  #通訊迴圈,可以不斷的收發訊息
      # 5.收發訊息
      data = conn.recv(1024)  # 1024個位元組 1.單位:bytes 2.1024代表最大接收1024個bytes
      print(data)
  ​
      conn.send(data.upper())
  ​
  # 6.掛電話
  conn.close()

  

 

客戶端

  
  import socket
  ​
  # 1.買手機  客戶端的phone 相當於服務端的 conn
  phone = socket.socket(socket.AF_INET,socket.SOCK_STREAM)  # 基於網路通訊的 基於tcp通訊的套接字
  ​
  # 2.撥號 (服務端的ip 和服務端的 埠)
  phone.connect(('127.0.0.1',8080))   #phone 拿到可以發收訊息的管道  連結物件phone,建立了與服務端的連結
  ​
  while True:
      # 3.發收訊息  bytes型
      msg = input("請輸入:")
      phone.send(msg.encode('utf-8'))
      data = phone.recv(1024)
      print(data)
  ​
  # 4.關閉
  phone.close()

  

 

注意:這裡的發訊息收訊息都不能為空,否則會出現錯誤。

這裡只能接收一個連結,不能迴圈接收連結,即打一次電話不能再打了,只能重新開機(重新執行程式)再打,

所以這裡要加上鍊接迴圈。

加上鍊接迴圈

服務端

  
  import socket
  ​
  phone = socket.socket(socket.AF_INET,socket.SOCK_STREAM)
  phone.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1)
  #這裡是重用ip和埠,防止出現地址被佔用的情況,即time_wait狀態
  phone.bind(('127.0.0.1',8080))
  phone.listen(5)
  while True: #連線迴圈 沒有併發 但可一個一個 接收客戶端的請求,一個連結結束,另外一個連結進來
      print('伺服器開始執行啦...')
      conn,client_addr = phone.accept()  # 現在沒併發 只能一個一個
      print(client_addr)
  ​
      while True:
          try:         # try...except 出異常適合windows 出異常這裡指客戶端斷開,防止服務端直接終止
              data = conn.recv(1024)
              if not data:break  #linux客戶端意外斷開,這裡接收的就是空,防止接收為空的情況
              print('客戶端資料:',data)
              conn.send(data.upper())
          except ConnectionResetError:
              break
      conn.close()
  phone.close()
  ​
  # 針對客戶端意外斷開的兩種情況
  #使用try ...except 是防止客戶端意外斷開產生
  # ConnectionResetError: [WinError 10054] 遠端主機強迫關閉了一個現有的連線。
  # 錯誤,針對windows系統
  ​
  # linux客戶端意外斷開,這裡接收的就是空,防止接收為空的情況
  # 用if 判斷接收的訊息是否為空

  

客戶端

  
  import socket
  phone = socket.socket(socket.AF_INET,socket.SOCK_STREAM)
  phone.connect(('127.0.0.1',8080))
  while True:
      msg = input('msg>>>:').strip() # ''
      if not msg:continue  #防止輸入為空的情況
      phone.send(msg.encode('utf-8'))  # b''
      data = phone.recv(1024)
      print(data.decode('utf-8')) #解碼
  ​
  phone.close()

  

附:一個服務端,多個客戶端,將一個客戶端複製多個相同的檔案,同時執行多個相同程式碼的客戶端檔案即可實現多個客戶端連結服務端,但是這種連結不是同時的,只能一個客戶端通訊完,另外一個客戶端在連線池(backlog設定的內容)裡等著,等一個連結結束才能開始通訊。

 

二、基於tcp實現遠端執行命令

模擬ssh遠端執行命令 ,執行命令即Windows的命令提示行裡輸入命令,在linux的終端輸入命令

通過tcp模擬執行命令並獲得結果,這裡需要用到subprocess模組

  如何執行系統命令: 並拿到執行結果
  import os
  os.system # 只能拿到 執行結果 0 執行成功 非0 失敗
  一般用:
      import subprocess
      obj = subprocess.Popen('dir d:',shell=True) # shell 啟了一個cmd
      把命令結果丟到管道里面:
          subprocess.Popen('dir d:',shell=True,
                stdout=subprocess.PIPE)
print(obj.stdout.read().decode('gbk'))拿到命令的結果
print(obj.stderr.read().decode('gbk'))拿到產生的錯誤,Windows系統用'gbk'編碼,linux用'utf-8'編碼
#且只能從管道里讀一次結果

 

例子

服務端

  import socket
  import subprocess
  ​
  ip_port = ("127.0.0.1",8000)
  buffer_size = 1024
  tcp_server = socket.socket(socket.AF_INET,socket.SOCK_STREAM)
  tcp_server.bind(ip_port)
  tcp_server.listen(5)
  ​
  while True:
      print("伺服器開始執行啦")
      conn,addr = tcp_server.accept()
      # print("conn是",conn)
      while True:
          try:
              # 1、收到命令
              cmd = conn.recv(buffer_size)
              print("收到客戶端的命令",cmd.decode("utf-8"))
              # 2、執行命令,拿到結果
              p = subprocess.Popen(cmd.decode("utf-8"),stdout=subprocess.PIPE,stdin=subprocess.PIPE,stderr=subprocess.PIPE,shell=True)
              res_cmd_err = p.stderr.read()
              res_cmd_out = p.stdout.read()  #這裡產生的結果Windows的編碼是'gbk',linux是'utf-8'
              # print("res_cmd——out",res_cmd_out)
              if  res_cmd_err: #出現錯誤
                  res_cmd = res_cmd_err
                  conn.send(res_cmd)
              else:
                  if not res_cmd_out:  #命令正常執行,但沒有返回值
                      res_cmd = "命令執行成功!"
                      conn.send(res_cmd.encode("gbk"))  #3、將結果返回給客戶端,注意Windows和linux的編碼不同
                  else:
                      conn.send(res_cmd_out)
          except Exception as e:
              print(e)
              break
      conn.close()

  

 

 

客戶端

  
  import socket
  ​
  ip_port = ("127.0.0.1",8000)
  buffer_size = 1024
  tcp_client = socket.socket(socket.AF_INET,socket.SOCK_STREAM)
  tcp_client.connect(ip_port)
  while True:
      # 1、發命令
      cmd = input("請輸入命令:").strip()
      if not cmd:continue
      if cmd == "quit":break
      tcp_client.send(cmd.encode("utf-8"))
      # 2、接收命令,但是這裡接收的資料量可能大於buffersize,即一次接收不完,下次通訊接收的是上次未接收完的資料,就會產生粘包現象
      res = tcp_client.recv(buffer_size)
      print(res.decode("gbk"))  #注意Windows和linux的編碼不同
  tcp_client.close()

  

 

三、tcp粘包現象

須知:只有TCP有粘包現象,UDP永遠不會粘包。

socket收發訊息的底層原理

收發訊息流程

1、傳送方的應用程式將位元組要傳送的訊息複製到自己的快取(記憶體),作業系統(os)通過呼叫網絡卡將快取的訊息傳送到接收方的網絡卡

2、接收方網絡卡將訊息存在自己作業系統的快取中,接收方的應用程式從自己的快取中取出訊息

總結

1、程式的記憶體和os(作業系統)的記憶體兩個記憶體互相隔離,程式的記憶體是使用者態 的記憶體,作業系統的記憶體是核心態的記憶體

2、傳送訊息是將使用者態的記憶體複製給核心態的記憶體

3、傳送方遵循tcp協議將訊息通過網絡卡傳送給接收方,接收方通知接收方的作業系統呼叫網絡卡接收資料,還要講記憶體態的訊息複製到使用者態的記憶體

4、傳送方訊息複製給自己核心態的記憶體速度快時間短,接收方要通知OS收訊息,還要複製訊息,用時長

不管是recv還是send都不是直接接收對方的資料,而是操作自己的作業系統記憶體,不是一個send對應一個recv

基於tcp的套接字客戶端往服務端上傳檔案,傳送時檔案內容是按照一段一段的位元組流傳送的,在接收方看了,根本不知道該檔案的位元組流從何處開始,在何處結束。

所謂粘包問題主要還是因為接收方不知道訊息之間的界限,不知道一次性提取多少位元組的資料所造成的。

傳送方引起的粘包是由TCP協議本身造成的,TCP為提高傳輸效率,傳送方往往要收集到足夠多的資料後才傳送一個TCP段。若連續幾次需要send的資料都很少,通常TCP會根據優化演算法(Nagle演算法,將多次間隔較小且資料量小的資料,合併成一個大的資料塊,然後進行封包)把這些資料合成一個TCP段後一次傳送出去,這樣接收方就收到了粘包資料。

 

兩種情況下會發生粘包

1、傳送端需要等緩衝區滿才傳送出去,造成粘包(傳送資料時間間隔很短,資料了很小,會合到一起,產生粘包)

2、接收方不及時接收緩衝區的包,或者由於buffersize的限制,一次接收不完,造成多個包接收(客戶端傳送了一段資料,服務端只收了一小部分,服務端下次再收的時候還是從緩衝區拿上次遺留的資料,產生粘包)

 

例子

服務端

  
  import socket
  import time
  server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  server.bind(('127.0.0.1', 9999))
  server.listen(5)
  ​
  print('... 開始執行...')
  conn, addr = server.accept()
  ​
  #data1 = conn.recv(1024)
  ​
  data1 = conn.recv(1)  # 當只取一個字元的時候,剩下的資料還在快取池裡面,下次接收時間很短的話,
  # 會繼續把上次沒接收完的一起取出來,就發生的粘包現象
  print('第一次', data1)
  ​
  data2 = conn.recv(1024)
  print('第二次', data2)
  ​
  conn.close()
  server.close()

  

客戶端

  # 兩次send:資料量小,時間間隔很短,會發生粘包
  ​
  import socket
  import time
  ​
  client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  client.connect(('127.0.0.1', 9999))
  ​
  client.send('hello'.encode('utf-8'))
  ​
  # time.sleep(1)  #兩次send直接隔一段時間,不會發生粘包現象
  ​
  client.send('world'.encode('utf-8'))
  ​
  client.close()

  

四、解決粘包問題

粘包問題產生的根源是接收方不知道一次提取多少位元組的資料,那麼需要傳送方在傳送資料前告知接收方我這次要傳送多少位元組的資料即可。

解決方式的簡單版

先用struct 傳送固定長度的訊息,傳遞要傳送訊息的長度,然後按照這個長度接收訊息

服務端

  import socket
  import subprocess
  import struct
  ​
  ip_port = ("127.0.0.1",9001)
  back_log = 5
  buffer_size = 1024
  ​
  tcp_server = socket.socket(socket.AF_INET,socket.SOCK_STREAM)
  tcp_server.bind(ip_port)
  tcp_server.listen(back_log)
  ​
  while True:
      conn,addr = tcp_server.accept()
      print("伺服器開始執行啦!")
      while True:
          try:
              cmd = conn.recv(buffer_size)
              if not cmd: break
              p = subprocess.Popen(cmd.decode("utf-8"), stderr=subprocess.PIPE, stdin=subprocess.PIPE,
                                   stdout=subprocess.PIPE, shell=True)
              err = p.stderr.read()
              if err:
                  res_cmd = err
              else:
                  res_cmd = p.stdout.read()
              if not res_cmd:
                  res_cmd = "執行成功!".encode("gbk")
                  print("命令已經執行!")
  ​
              # 第一步:獲取結果訊息的長度
              length = len(res_cmd)
              # 第二步:將結果訊息的長度封裝為一個固定長度的報頭
              header = struct.pack("i", length)
              # 第三步:先向接收方傳送報頭,使接收方知道真正接收的訊息是多長,
              # 然後根據這個長度來重複迴圈接收訊息
              conn.send(header)
              conn.send(res_cmd)
          except Exception as e:
              print(e)
              break
      conn.close()

  

 

客戶端

 

  import socket
  import struct
  ​
  ip_port = ("127.0.0.1", 9001)
  buffer_size = 1024
  ​
  tcp_client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  tcp_client.connect(ip_port)
  ​
  while True:
      cmd = input("請輸入命令:")
      if not cmd: continue
      if cmd == "quit": break
      tcp_client.send(cmd.encode("utf-8"))
      # 第一步:接收一個固定長度的報頭
      header = tcp_client.recv(4)
      # 第二步:解碼獲取報頭裡隱藏的真實要接收訊息的長度
      res_length = struct.unpack("i", header)[0]
      # 第三步:根據訊息的長度來不斷的迴圈收取訊息
      recv_data = b""
      recv_data_size = 0
      while recv_data_size < res_length:
          res_cmd = tcp_client.recv(buffer_size)
          recv_data = recv_data + res_cmd
          recv_data_size = len(recv_data)
      print("收取的資料是", recv_data.decode("gbk"))
  ​
  tcp_client.close()

  

  

解決方式終極版

通過自定義的報頭來傳遞除了訊息長度外更多的訊息,為傳遞的訊息做一個字典。

服務端

 

  import socket
  import subprocess
  import struct
  import json
  ​
  ip_port = ("127.0.0.1", 9000)
  back_log = 5
  buffer_size = 1024
  tcp_server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  tcp_server.bind(ip_port)
  tcp_server.listen(back_log)
  ​
  while True:
      print("伺服器開始執行啦!")
      conn, address = tcp_server.accept()
      while True:
          try:
              cmd = conn.recv(buffer_size)
              if not cmd: continue
              p = subprocess.Popen(cmd.decode("utf-8"), stderr=subprocess.PIPE, stdin=subprocess.PIPE,
                                   stdout=subprocess.PIPE, shell=True)
              err = p.stderr.read()
              # print(err)
              if err:
                  res_cmd = err
              else:
                  res_cmd = p.stdout.read()
                  # print(res_cmd)
              # print(res_cmd)
              if not res_cmd:
                  res_cmd = "已經執行啦!".encode("gbk")
              res_length = len(res_cmd)
              # 第一步:製作自定製的字典作為報頭,儲存多種資訊
              header_dict = {
                  "filename": "a.txt",
                  "md5": "7887414147774415",
                  "size": res_length
              }
              # 第二步:將字典序列化轉為json字串,然後進行編碼轉成bytes,以便於直接網路傳送
              header_bytes = json.dumps(header_dict).encode("utf-8")
              # 第三步:獲得這個報頭的長度
              header_length = len(header_bytes)
              # 第四步:將報頭的長度打包成固定的長度,以便接收方先接收報頭
              send_header = struct.pack("i", header_length)
              # 第五步:先發送報頭的長度
              conn.send(send_header)
              # 第六步:傳送報頭
              conn.send(header_bytes)
              # 第七步:傳送真實的訊息
              conn.send(res_cmd)
          except Exception as e:
              print(e)
              break
      conn.close()

  

 

客戶端

 

import socket
import struct
import json

ip_port = ("127.0.0.1", 9000)
buffer_size = 1024
tcp_client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
tcp_client.connect(ip_port)

while True:
    cmd = input("請輸入命令:")
    if not cmd: continue
    if cmd == "quit":break
    tcp_client.send(cmd.encode("utf-8"))
    # 第一步:接收報頭的長度資訊
    header_length = tcp_client.recv(4)
    # 第二步:獲取報頭的長度,解碼獲取報頭的長度
    header_size = struct.unpack("i", header_length)[0]
    # 第三步:根據報頭的長度資訊接收報頭資訊
    header_bytes = tcp_client.recv(header_size).decode("utf-8")
    # 第四步:根據接收的報頭資訊反序列化獲得真實的報頭
    header_dict = json.loads(header_bytes)
    print("客戶端收到的報頭字典是",header_dict)
    # 第五步:根據報頭字典獲取真實訊息的長度
    res_size = header_dict["size"]
    # 第六步:根據獲取的真實訊息的長度不斷迴圈獲取真實訊息
    data = b""
    data_size = 0
    while data_size < res_size:
        recv_data = tcp_client.recv(buffer_size)
        data = data + recv_data
        data_size = len(data)
    print("接收的資料是", data.decode("gbk"))

tcp_client.close()