深入淺出談 socket

貓xian森發表於2017-03-16

現在我們開發往往不斷使用封裝好的web框架, 執行web服務也有相當多的容器, 但是其原理往往都離不開socket. 像是nginx底層就是採用類似python中epoll的非同步監聽方式加上socket結合來做. 本文采取從最簡單的socket通訊實現聊天機器人, 到偽併發實現聊天機器人, 最後採用非同步監聽方式實現聊天機器人, 逐步推進.

首先我們實現一個最簡單版的的socket服務端, server_s1.py

#!/usr/bin/env python
# -*- coding:utf-8 -*-
import socket

HOST='127.0.0.1'
PORT=9999

sockaddr=(HOST,PORT)
sk=socket.socket()
sk.bind(sockaddr)
sk.listen(5)
conn,address=sk.accept()
ret_bytes=conn.recv(1024)
print(str(ret_bytes,encoding='utf-8'))
conn.sendall(ret_bytes+bytes(', 已收到!',encoding='utf-8'))
sk.close()複製程式碼
  • sk=socket.socket() 這裡建立socket物件
  • 通過sk.bind(sockaddr) 傳入一個元組物件以此來設定服務端ip和port
  • sk.listen(5) 表示設定最大等待連線數為5個
  • conn,address=sk.accept() 此時阻塞程式, 迴圈等待被連線, 返回連線物件和包含連線資訊的物件
  • ret_bytes=conn.recv(1024) 等待接受1024個位元組的資訊
  • conn.sendall(ret_bytes+bytes(', 已收到!',encoding='utf-8')) 將接受的資訊加上 , 已收到! 重新傳送給客戶端. 注意, 在python2中可以傳遞str型別的資料, 但是在python3中只能傳遞byte型別的資料
  • sk.close() 關閉連線

至此簡單的服務端已經寫好了, 我們看看客戶端, client_c1.py

#!/usr/bin/env python
# -*- coding:utf-8 -*-
import socket

HOST='127.0.0.1'
PORT=9999

sockaddr=(HOST,PORT)
ct=socket.socket()
ct.connect(sockaddr)
ct.sendall(bytes('第一次連線',encoding='utf-8'))
ret_bytes=ct.recv(1024)
print(str(ret_bytes,encoding='utf-8'))
ct.close()複製程式碼
  • 客戶端中需要連線服務端, 通過ct.connect(sockaddr) 來執行

到現在為止, 已經把簡單聊天機器人已經寫好了, 客戶端向服務端傳送第一次連線 , 服務端接受輸出到客戶端並回饋給客戶端第一次連線, 已收到! 接下來我們試著讓這個服務端更健壯一些, 嘗試讓它可以不斷的返回客戶端傳送過來的內容

這是第二個版本的服務端, server_s2.py

#!/usr/bin/env python
# -*- coding:utf-8 -*-
import socket

HOST='127.0.0.1'
PORT=9999

sockaddr=(HOST,PORT)
sk=socket.socket()
sk.bind(sockaddr)
sk.listen(5)
while True:
    conn,address=sk.accept()
    while True:
        try:
            ret_bytes=conn.recv(1024)
        except Exception as ex:
            print("已從",address,"斷開")
            break
        else:
            conn.sendall(ret_bytes+bytes(', 已收到!',encoding='utf-8'))
sk.close()複製程式碼
  • 最內層的迴圈表示一旦連線則一直等待客戶端傳送訊息併發回去, 直到連線斷開
  • 最外層的迴圈表示即使斷開連線但是伺服器仍處於等待其他客戶端連線
  • 加入異常處理表示, 客戶端斷開連線, 服務端僅僅斷開此次連線

接下來看看客戶端檔案, client_c2.py

#!/usr/bin/env python
# -*- coding:utf-8 -*-
import socket

HOST='127.0.0.1'
PORT=9999

sockaddr=(HOST,PORT)
ct=socket.socket()
ct.connect(sockaddr)
while True:
    inp=input("請輸入要傳送的內容: ")
    ct.sendall(bytes(inp,encoding='utf-8'))
    ret_bytes=ct.recv(1024)
    print(str(ret_bytes,encoding='utf-8'))
ct.close()複製程式碼
  • 客戶端僅僅需要將要傳送內容的部分放到迴圈中即可

現在第二個版本已經可以連續不斷的處理同一連線的訊息, 即使斷開也不會影響伺服器的健壯性. 但是, 我們的伺服器功能還很單一, 只能一次處理一個客戶端的連線. 接下來將用select模組實現偽併發處理客戶端連線

這裡是第三個版本的服務端檔案, server_s3.py

#!/usr/bin/env python
# -*- coding:utf-8 -*-
import socket
import select

HOST = '127.0.0.1'
PORT = 9999

sockaddr = (HOST, PORT)

sk = socket.socket()
sk.bind(sockaddr)
sk.listen(5)

sk_inps = [sk, ]

while True:
    change_list, keep_list, error_list = select.select(sk_inps, [], sk_inps, 1)
    for sk_tmp in change_list:
        if sk_tmp == sk:
            conn, address = sk_tmp.accept()
            sk_inps.append(conn)
        else:
            try:
                ret_bytes = sk_tmp.recv(1024)
            except Exception as ex:
                sk_inps.remove(sk_tmp)
                print("已從", sk_tmp.getpeername(), "斷開")
            else:
                sk_tmp.sendall(ret_bytes + bytes(', 已收到!', encoding='utf-8'))

    for sk_tmp in error_list:
        sk_inps.remove(sk_tmp)

sk.close()複製程式碼

我們首先來看一下迴圈的過程

深入淺出談 socket
迴圈過程

  • change_list, keep_list, error_list = select.select(sk_inps, [], sk_inps, 1) 中, select.select() 會自動監控起引數的內容, 當第一個引數中的物件發生變化時候會將該物件加到change_list中, 該次迴圈結束時change_list便會自動清空. 第一個引數中的變化對於sk物件, 這裡只有客戶端連線sk物件或者與sk物件斷開兩種情況
  • 接著我們遍歷change_lis中的內容, 當有客戶端連線時候, 如圖所見, chang_list中只有sk物件, 此時我們將客戶端的連線conn加入到sk_inps中, 讓select下次迴圈時候也監控conn物件的變化
  • 當客戶端傳送訊息時候意味著conn物件的變化, 此時change_list中加入該連線物件, 根據此物件, 我們可以處理客戶端傳送來的訊息
  • 通過以上方式, 讓服務端輪流處理每個客戶端連線, 由於cpu現在的處理速度極快, 給人的感覺就是併發處理多個客戶端請求, 實際上是偽裝併發處理
  • sk_inps.remove(sk_tmp) 這一句中, 一旦客戶端斷開連線, 則服務端就會捕捉到異常並將該客戶端物件從監控列表sk_inps中移除
  • 接著我們來說是select.select() 中的第二個引數, 該引數中有什麼物件則keep_list 中就會加入什麼物件, 該引數對於讀寫分離的偽併發處理有很大意義, 我們稍後再做介紹
  • select.select() 的第三個引數是當被監控的物件出現錯誤或者異常時候就將出錯的物件加入到error_list 中, 隨後我們遍歷error_list並根據裡邊的出錯物件將其從sk_inps中除去

該版本的客戶端延續上一版本即可, 無需更改. 至此, 我們就建立一個能併發簡單處理多客戶端連線的伺服器. 但是, 對於change_list 中遍歷時候我們既有讀又有寫的操作, 這樣當後期的處理複雜的時候, 程式碼維護很難再進行下去. 接下來我們接著開發我們的偽併發處理的最終版本

這裡是服務的檔案, server_s4.py

#!/usr/bin/env python
# -*- coding:utf-8 -*-
import socket
import select

HOST = '127.0.0.1'
PORT = 9997

sockaddr = (HOST, PORT)

sk = socket.socket()
sk.bind(sockaddr)
sk.listen(5)

sk_inps = [sk, ]
sk_outs=[]
message_dic={}

while True:
    change_list, keep_list, error_list = select.select(sk_inps, sk_outs, sk_inps, 1)
    for sk_tmp in change_list:
        if sk_tmp == sk:
            conn, address = sk_tmp.accept()
            sk_inps.append(conn)
            message_dic[conn]=[]
        else:
            try:
                ret_bytes = sk_tmp.recv(1024)
            except Exception as ex:
                sk_inps.remove(sk_tmp)
                print("已從", sk_tmp.getpeername(), "斷開")
                del message_dic[sk_tmp]
            else:
                sk_outs.append(sk_tmp)
                message_dic[sk_tmp].append(str(ret_bytes,encoding='utf-8'))

    for conn in keep_list:
        message= message_dic[conn][0]
        conn.sendall(bytes(message+", 已收到!",encoding='utf-8'))
        del message_dic[conn][0]
        sk_outs.remove(conn)

    for sk_tmp in error_list:
        sk_inps.remove(sk_tmp)

sk.close()複製程式碼
  • sk_outs=[] 中儲存傳送訊息的客戶端連線物件
  • message_dic={} 中儲存訊息內容
  • 當客戶端傳送訊息時候, 我們在一個for迴圈中將其連線物件和訊息內容分別儲存起來, 在第二個迴圈中我處理訊息內容

以上就是偽併發處理客戶端請求所有內容, 究其本質其實是IO多路複用原理. 同時python中也提供了真正的併發處理模組socketserver, 下面我們採用socketserver來實現

首先看我們的服務端檔案, server_s5.py

#!/usr/bin/env python
# -*- coding:utf-8 -*-
import socketserver

HOST = '127.0.0.1'
PORT = 9997

sockaddr = (HOST, PORT)

class MySocket(socketserver.BaseRequestHandler):
    def handle(self):
        conn = self.request
        while True:
            try:
                ret_bytes = conn.recv(1024)
            except Exception as ex:
                print("已從", self.client_address, "斷開")
                break
            else:
                conn.sendall(ret_bytes + bytes(', 已收到!', encoding='utf-8'))


if __name__ == "__main__":
    server = socketserver.ThreadingTCPServer(sockaddr, MySocket)
    server.serve_forever()複製程式碼
  • 其原理只是將上述的IO多路複用改成了threading執行緒處理, 再加上本來的Socket內容形成
  • server = socketserver.ThreadingTCPServer(sockaddr, MySocket) 該句會將Socket服務端設定ip和port等內容封裝到物件中, 執行初始化時候需要加入自己寫的繼承socketserver.BaseRequestHandler的類
  • server.serve_forever() 此句執行時候會使得物件呼叫handle(self) 方法, 在該方法中我們對客戶端連線進行處理

以上我們將Socket從基礎原理到複雜自定義已經使用封裝好的模組使用介紹完畢. 接下來我們補充一些理論知識和常用的Socket引數和方法:
首先我們來回顧一下OSI模型和TCP/IP協議簇,如圖(圖片引自網路)

深入淺出談 socket
OSI模型與TCP/IP協議簇

每層都有相對應的協議,但是socket API只是作業系統提供的一個用於網路程式設計的介面, 如圖(圖片引自網路)

深入淺出談 socket
socket與各層關係

根據 socket 傳輸資料方式的不同(其實就是使用協議的不同), 導致其與不同層打交道

  • Stream sockets, 是一種面向連線的 socket, 使用 TCP 協議.
  • Datagram sockets, 無連線的 socket,使用 UDP 協議.
  • Raw sockets, 通常用在路由器或其他網路裝置中, 這種socket直接由網路層通向應用層.

以下是注意點:

  • 在我們建立物件時候sk=socket.socket(family=AF_INET, type=SOCK_STREAM, proto=0, fileno=None) 實際上預設傳入了引數, 第一個參數列示ip協議, ocket.AF_INET表示ipv4協議(預設就是), 第二個參數列示傳輸資料格式, socket.SOCK_STREAM表示tcp協議(預設就是), socket.SOCK_DGRAM表示udp協議
  • ret_bytes=conn.recv(1024) 中表示最多接受1024個位元組; 若沒有接受到內容則會阻塞程式, 等待接受內容
  • send() 可能會傳送部分內容, sendall()本質就是內部迴圈呼叫send()直到將內容傳送完畢, 建議使用sendall()
  • 當用socket做ftp檔案傳輸時候會產生粘包問題, 此時只需在傳送檔案大小之後等待接受服務端返回一個確認碼後, 再傳送檔案即可解決