基於socket的簡單聊天程序


title: 基於socket的簡單聊天程序 date: 2019-04-10 16:55:14 tags:html

  • Network

這是一個能夠自由切換聊天對象,也能傳輸文件,還有僞雲端聊天信息暫存功能的聊天程序!( ̄▽ ̄)"git


去年暑假的小學期的電子設計課上我用STC與電腦相互通訊,製做出了一個Rader項目(該項目的完整代碼在個人GitHub上)。這個項目地大體思想是:下位機(STC)使用步進電機帶動超聲波模塊採集四周的距離,而後用485串行總線上傳到上位機(電腦),上位機將這些數據收集並繪製略醜的雷達圖。因爲上下位機處理數據的速度不一致,容易致使不一樣步的現象。當時爲了解決這個問題,用了一個簡單的方法,如今發現這個方法和「停等協議」十分類似。github

這學期的計網實驗要求基於socket傳輸數據,相比於在485總線上實現停等協議,socket仍是很簡單的。安全

Naive版聊天程序

最簡單的socket通訊程序只須要兩個進程就能夠跑起來了,一個做爲服務端,另外一個做爲客戶端,而後二者之間傳輸數據。bash

# Server
import socket
from socket import AF_INET, SOCK_STREAM

serverSocket = socket.socket(AF_INET, SOCK_STREAM)
srv_addr = ("127.0.0.1", 8888)
serverSocket.bind(srv_addr)
serverSocket.listen()

print("[Server INFO] listening...")
while True:
    conn, cli_addr = serverSocket.accept()
    print("[Server INFO] connection from {}".format(cli_addr))
    message = conn.recv(1024)
    conn.send(message.upper())
    conn.close()
複製代碼
# Client
import socket
from socket import AF_INET, SOCK_STREAM

clientSocket = socket.socket(AF_INET, SOCK_STREAM)
srv_addr = ("127.0.0.1", 8888)

print("[Client INFO] connect to {}".format(srv_addr))
clientSocket.connect(srv_addr)

message = bytes(input("Input lowercase message> "), encoding="utf-8")
clientSocket.send(message)

modifiedMessage = clientSocket.recv(1024).decode("utf-8")
print("[Client INFO] recv: '{}'".format(modifiedMessage))
clientSocket.close()
複製代碼

多用戶版

上面這種模式是十分naiive的。好比爲了切換用戶(假設不一樣用戶在不一樣的進程上),就只能先kill原先的進程,而後修改代碼中的IP和Port,最後花了1分鐘時間才能開始聊天。並且這種方式最大的缺陷是隻有知道了對方的IP和Port以後才能開始聊天。服務器

爲了解決Naiive版聊天程序的缺點,能夠構建以下C/S拓撲結構。session

這個拓撲的結構的核心在於中央的Server,全部Client的鏈接信息都會被保存在Server上,Server負責將某個Client的聊天信息轉發給目標Client。數據結構

數據格式設計

像TCP協議須要報文同樣,這個簡單聊天程序的信息轉發也須要Server識別每一條信息的目的,才能準確轉發信息。這就須要設計協議報文的結構(顯然這是在應用層上的實現)。因爲應用場景簡單,我是用的協議結構以下:併發

sender|receiver|timestamp|msg
複製代碼

這是一個四元組,每一個元素用管道符|分割。具體來講每一個Client(客戶進程)發送數據給Server以前都會在msg以前附加:發送方標識sender、接受方標識receiver以及本地時間戳timestamp。對應的代碼端以下:socket

info = "{}|{}|{}|{}".format(self.UserID, targetID, timestamp, msg)
複製代碼

這樣Server接收到報文以後就能「正確」轉發消息了。

這裏的「正確」被加上了引號,這是爲何?由於在我設計該乞丐版協議的時候簡化場景中只存在惟一用戶ID的場景,若是有個叫「Randool」的用戶正在和其餘用戶聊天,這個時候另外一個「Randool」登錄了聊天程序,那麼前者將不能接收信息(除非再次登陸)。不過簡單場景下仍是可使用的。

解決方法能夠是在Client登陸Server時添加驗證的步驟,讓重複用戶名沒法經過驗證。

消息隊列

該聊天程序使用的傳輸層協議是TCP,這是可靠的傳輸協議,但聊天程序並不能保證雙方必定在線吧,聊天一方在任什麼時候候均可以退出聊天。可是一個健壯的聊天程序不能讓信息有所丟失,因爲傳輸層已經不能確保信息必定送達,那麼只能寄但願於應用層。

因爲消息是經過Server轉發的,那麼只要在Server上爲每個Client維護一個消息隊列便可。數據結構以下:

MsgQ = {}
Q = MsgQ[UserID]
複製代碼

使用這種數據結構就能夠模擬雲端聊天記錄暫存的功能了!

文件傳輸

文件傳輸本質上就是傳輸消息,只不過文件傳輸的內容不是直接顯示在屏幕上罷了。相比於純聊天記錄的傳輸,文件傳輸須要多附加上文件名,

base64編碼傳輸

普通的聊天信息中不會出現管道符,可是代碼和字符表情就不必定了∑( 口 ||,若是信息中出現了管道符就會致使協議解析失效,所以須要一種方法將msg中的|隱藏掉。思路是轉義,可是這個須要手工重寫協議解析代碼,不夠美觀。因爲以前瞭解過信息安全中的相關知識,還記得有一種編碼方式是base64,因爲base64編碼結果不會出現管道符,那麼問題就簡單了,只須要用base64將傳輸信息從新編碼一番。而且這是一種「即插即用」的方式,只要自定義base64的編碼解碼函數,而後嵌套在待發送msg的外面便可。

import base64

b64decode = lambda x: base64.b64decode(x.encode()).decode()
b64encode = lambda x: base64.b64encode(x.encode()).decode()
複製代碼

將發送信息改寫爲以下形式:

info = "{}|{}|{}|{}||".format(self.UserID, targetID, timestamp, b64encode(msg))
複製代碼

終端高亮顯示

樸素的文字打印在屏幕上難以區分主次,用戶體驗極差,所以可使用終端高亮的方法凸顯重要信息。在網上查到了一種高亮的方式,可是僅限於Linux系統。其高亮顯示的格式以下:

\033[顯示方式;前景色;背景色mXXXXXXXX\033[0m

中間的XXXXXXXX就是須要顯示的文字部分了。顯示方式,前景色,背景色是可選參數,能夠只寫其中的某一個;另外因爲表示三個參數不一樣含義的數值都是惟一的沒有重複的,因此三個參數的書寫前後順序沒有固定要求,系統都能識別;可是,建議按照默認的格式規範書寫。

這個部分參考了Python學習-終端字體高亮顯示,所以對於參數的配置方面再也不多說

效果

有多種終端分屏插件,這裏推薦tmux,上面的分屏效果使用的就是tmux

代碼實現

服務端代碼

import queue
import socket
import time

import _thread


hostname = socket.gethostname()
port = 12345

""" The info stored in the queue should be like this: "sender|receiver|timestamp|msg" and all item is str. """
MsgQ = {}


def Sender(sock, UserID):
    """ Fetch 'info' from queue send to UserID. """
    Q = MsgQ[UserID]
    try:
        while True:
            # get methord will be blocked if empty
            info = Q.get()
            sock.send(info.encode())
    except Exception as e:
        print(e)
        sock.close()
        _thread.exit_thread()


def Receiver(sock):
    """ Receive 'msg' from UserID and store 'info' into queue. """
    try:
        while True:
            info = sock.recv(1024).decode()
            print(info)
            info_unpack = info.split("|")
            receiver = info_unpack[1]
            
            exit_cmd = receiver == "SEVER" and info_unpack[3] == "EXIT"
            assert not exit_cmd, "{} exit".format(info_unpack[0]) 
            
            if receiver not in MsgQ:
                MsgQ[receiver] = queue.Queue()
            MsgQ[receiver].put(info)

    except Exception as e:
        print(e)
        sock.close() 
        _thread.exit_thread()


class Server:
    def __init__(self):
        self.Sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        self.Sock.bind((hostname, port))
        self.Sock.listen()
        # self.threads = []

    def run(self):
        print("\033[35;40m[ Server is running ]\033[0m")
        # print("[ Server is running ]")
        while True:
            sock, _ = self.Sock.accept()

            # Register for new Client
            UserID = sock.recv(1024).decode()
            print("Connect to {}".format(UserID))

            # Build a message queue for new Client
            if UserID not in MsgQ:
                MsgQ[UserID] = queue.Queue()

            # Start two threads
            _thread.start_new_thread(Sender, (sock, UserID))
            _thread.start_new_thread(Receiver, (sock,))

    def close(self):
        self.Sock.close()


if __name__ == "__main__":
    server = Server()
    try:
        server.run()
    except KeyboardInterrupt as e:
        server.close()
        print("Server exited")
複製代碼

客戶端代碼

import socket
import sys, os
import time
import base64
import _thread
from SktSrv import hostname, port

b64decode = lambda x: base64.b64decode(x.encode()).decode()
b64encode = lambda x: base64.b64encode(x.encode()).decode()


def Receiver(sock):
    from_id = ""
    fr = None   # file handle
    while True:
        info = sock.recv(1024).decode()
        info_unpacks = info.split("||")[:-1]
        for info_unpack in info_unpacks:
            sender, _, timestamp, msg = info_unpack.split("|")
            msg = b64decode(msg)    # base64解碼

            # Start a new session
            if from_id != sender:
                from_id = sender
                print("==== {} ====".format(sender))
            
            if msg[:5] == "@FILE":  # FILENAME,FILE,FILEEND
                # print(msg)
                if msg[:10] == "@FILENAME:":
                    print("++Recvive {}".format(msg[9:]))
                    fr = open(msg[10:]+".txt", "w")
                elif msg[:9] == "@FILEEND:":
                    fr.close()
                    print("++Recvive finish")
                elif msg[:6] == "@FILE:":
                    fr.write(msg[6:])
                continue

            show = "{}\t{}".format(timestamp, msg)
            print("\033[1;36;40m{}\033[0m".format(show))


class Client:
    def __init__(self, UserID: str=None):
        if UserID is not None:
            self.UserID = UserID
        else:
            self.UserID = input("login with userID >> ")
        self.Sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        self.server_addr = (hostname, port)

    def Sender(self):
        """ Send info: "sender|receiver|timestamp|msg" Change to name: '@switch:name' Trans file: '@trans:filename' """
        targetID = input("Chat with > ")
        while True:
            msg = input()
            if not len(msg):
                continue

            lt = time.localtime()
            timestamp = "{}:{}:{}".format(lt.tm_hour, lt.tm_min, lt.tm_sec)

            if msg == "@exit":          # 退出
                print("Bye~")
                return

            elif msg == "@help":
                continue

            elif msg[:8] == "@switch:": # 切換聊天對象
                targetID = msg.split(":")[1]
                print("++Switch to {}".format(targetID))
                continue
            
            elif msg[:7] == "@trans:":  # 發送文件
                filename = msg.split(":")[1]
                if not os.path.exists(filename):
                    print("!!{} no found".format(filename))
                    continue
                print("++Transfer {} to {}".format(filename, targetID))
                head = "{}|{}|{}|{}||".format(self.UserID, targetID, timestamp, b64encode("@FILENAME:"+filename))
                self.Sock.send(head.encode())
                with open(filename, "r") as fp:
                    while True:
                        chunk = fp.read(512)
                        if not chunk:
                            break
                        chunk = "{}|{}|{}|{}||".format(self.UserID, targetID, timestamp, b64encode("@FILE:"+chunk))
                        self.Sock.send(chunk.encode())
                tail = "{}|{}|{}|{}||".format(self.UserID, targetID, timestamp, b64encode("@FILEEND:"+filename))
                self.Sock.send(tail.encode())
                print("++Done.")
                continue
            
            info = "{}|{}|{}|{}||".format(self.UserID, targetID, timestamp, b64encode(msg))
            self.Sock.send(info.encode())

    def run(self):
        try:
            self.Sock.connect(self.server_addr)
            print("\033[35;40m[ Client is running ]\033[0m")
            # print("[ Client is running ]")

            # Register UserID
            self.Sock.send(self.UserID.encode())

            # Start Receiver threads
            _thread.start_new_thread(Receiver, (self.Sock,))
            self.Sender()   # Use for Send message

        except BrokenPipeError:
            print("\033[1;31;40mMissing connection\033[0m")

        finally:
            print("\033[1;33;40mYou are offline.\033[0m")
            self.exit_client()
            self.Sock.close()

    def exit_client(self):
        bye = "{}|{}|{}|{}".format(self.UserID, "SEVER", "", "EXIT")
        self.Sock.send(bye.encode())


if __name__ == "__main__":
    client = Client()
    client.run()
複製代碼

P2P版

上面的多用戶版聊天程序雖然能夠實現靈活的用戶切換聊天功能,可是實際上因爲全部的數據都會以服務器爲中轉站,會對服務器形成較大的壓力。更加靈活的結構是使用P2P的方式,數據只在Client間傳輸。應該是將服務器視爲相似DNS服務器的角色,只維護一個Name <--> (IP,Port)的查詢表,而將鏈接信息轉移到Client上。

存在的問題

P2P版本的聊天程序並不僅是實現上述的功能就能夠了,考慮到前邊「消息隊列」中實現的功能:在用戶退出後,聊天信息須要能保存在一個可靠的地方。既然聊天雙方都存在退出的可能,那麼在這個場景下這個「可靠的地方」就是服務器了。這也就是說P2P版本的Client除了創建與其餘Client之間的TCP鏈接,還須要一直保持和Server的鏈接!

注意這一點,以前是爲了減輕Server的壓力,減小鏈接的數量才使用P2P的模式的,可是在該模式爲了實現「消息隊列」的功能卻仍是須要Server保存鏈接。

改進方式

若是要進一步改善,能夠按照下面的方式:

  1. Client C1登陸時與Server創建鏈接,Server驗證其登陸合法性,而後斷開鏈接。
  2. C1選擇聊天對象C2,C2的IP等信息須要從Server中獲取,所以C1再次創建與Server的鏈接,完成信息獲取後,斷開鏈接。
  3. C1與C2的正常聊天信息不經過Server,而是真正的P2P傳輸。
  4. 聊天一方意外斷開後(假設爲C2),C1傳輸的信息沒法到達,而且C1能夠感知到信息沒法到達;這個時候C1再次創建與Server的鏈接,將未能送達的信息保存到Server上的「消息隊列」。

補充一點:在步驟2中,若是C2未上線或C2意外斷開,因爲Server並不能及時知道Client的信息,所以須要「心跳包機制」,Client登陸後定時向Server發送alive信息,Server收到信息後維持或更新信息。

這樣Server從始至終沒有一直維持着鏈接,鏈接數量是動態變化的,在查詢併發量較小的狀況下對服務器資源的利用率是很小的。

進一步能夠思考什麼?

若是有多個Server,如何規劃Server之間的拓撲?好比Fat-Tree之類的...

相關文章
相關標籤/搜索