基于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之类的...

相关文章
相关标签/搜索