设计一个类似于QQ或MSN的聊天系统

 

设计一个类似于QQ或MSN的聊天系统

前言:这篇文章是曾经网络程序设计课程后期习老师布置的一门大作业。当时感觉很头疼,持续了很久的一段时间,后来慢慢释然,从简单开始,先构建聊天系统功能选择架构,然后再不断添砖加瓦;编程环境和操作系统环境对程序的编写影响不可小视,我当时使用Ubuntu16的版本在python3的基础上结合mysql数据库操作完成本作品,记录一波当时的代码和报告,希望对大家的学习有所帮助。


一、功能设计

  新用户注册

  用户登录和退出

  聊天服务

  在线用户查询

  文件传送服务

  文件断点续传

二、项目整体设计框架图及说明、流程图

三、模块设计说明

服务器1:

新用户注册:  用户信息存储模块,用户-服务器注册模块

用户登录和退出:  用户-服务器登录模块,用户-服务器退出模块

服务器2:

在线用户查询:   用户-服务器查询模块

聊天服务:  用户-用户聊天模块

文件传送服务:  用户-用户文件传输模块

文件断点续传:  用户-用户文件传输模块改进

四、项目关键数据结构及说明

  0x01.首先是客户端主程序整体框架  

  根据用户输入的功能号码选择不同的功能,进入不同的处理函数。在进入主函数之前要定义一些全局变量:

  包括创建udp类型的socket套接字,定义不同服务器的的地址,初始化本地用户名、密码,初始化本地登录状态(后面聊天、传文件时先判断本地state是否为1,如果未登录则state==0,减少了对服务器的验证,简化程序)

 0x02.首先是数据库结构的设计,他存放着注册用户的一切信息,用户名、密码、ip地址、端口号、登录状态信息;如下图示:

  只有当用户登陆后,才把state置为1,同时更新ip_addr和port的值。用户退出时,把state置为1,而不必变化ip_addr\port的值,毕竟下一次登录时会自动更新嘛...

 0x03 其次,本聊天系统的精髓在于使用简易报文通过udp的socket在客户机和不同服务器之间信息交互。下面我依次来详细介绍一下:

  这是客户端的登录函数,它构造报文如下:

  1’是标志,后面是16进制形式表示的用户名长度密码长度,然后是用户名密码。从这里想必你也推断出我前面对输入用户名、密码长度的要求是不超过15位(最长用f来表示长度)。例如注册用户名为Jorden、密码是happy789  那么这个报文的形式为”168Jordenhappy789”。

  接受者即对应server1的地址,下面是server1的main函数,先收到数据,再根据报文的首个字母进入不同的处理函数,比如受到报文首字母为1,就进入DealRegister函数处理登录……以此类推

  DealRegister函数里可以看到,也是根据报文规则依次抽取有用信息,填入函数内的变量保存用户名、密码。之后则操纵数据库,新建用户的信息……后文函数说明仍有详细介绍。

  下面这个是登录函数的报文构造,它和注册函数非常像,就是把标志’1’换成标志’2’,其他都一样:

 

  下面是检查当前在线用户函数的报文,它非常简单,不需要任何参数,只用一个标志‘3’就可以代替,报文需要被发送给server2:

 

  下面是server2程序的main函数,可以看到它和server1主函数构造完全相同,只是报文标志不同的区别。

  

  而server2程序关于检查当前在线用户函数报文做出的回应就是检查数据表内state为1的所有用户名,把用户名拼接成字符串回馈给用户。     

  

  下面是聊天函数的报文构造,标志为’4’,后面紧跟聊天对象的用户名的长度、用户名,发给server2;

  

  Server2收到标志为‘4’的报文则从报文里面抽取出用户名、密码,后面构造sql查询语句后面再提。  

  

  最后是文件传输函数的报文构造,它和上一个聊天函数的构造一样,只是把标志换成了‘5’,发给server2。

  Server2的对文件传输功能的处理也和辽天功能的处理完全相似:  

  

  下面是注销函数得报文如下,它的标志为’6’:

  

  状态为0即未登录时直接退出,不需要服务器参与;否则要在报文里把自己的username发送给server1,等待server1把自己在数据库内的state修改为0,返回成功注销消息。

  

0x04 关键函数说明及流程图

  由于登录、注册退出、查询等等功能前面的叙述已经大体明了;我觉得本项目最重要的是对聊天功能、文件传输功能的注释,后面我集中精力解释这两个函数的实现。

  1.client 端聊天功能

 1 def Chat():
 2     global mystate
 3     if mystate == 0:
 4         print("You haven't login until now......")
 5         return
 6     print("Ready to check online user for you....")
 7     Check()                                 #返回所有登录用户名
 8     talk_obj=input("Choose someuser's name you would like to communicate:")
 9     payload='4'+str(hex(len(talk_obj))[2:])+talk_obj #选择渴望的聊天对象
10     mysock.sendto(payload.encode('gb2312'),sendAddr2)
11     recvData=mysock.recvfrom(1024)
12     ret_info=recvData[0].decode('gb2312')    
13     if ret_info[0]=='1':  #从server2返还报文中解析出聊天对象的ip地址、端口号
14         destIP=ret_info[3:3+int(ret_info[1],16)]
15         destPort=int(ret_info[3+int(ret_info[1],16):])
16         print("**************************************************\nConnecting with user %s[%s:%d]......"%(talk_obj,destIP,destPort))
17         tr= Thread(target=recv_Data,args=(talk_obj,))  
18         ts= Thread(target=send_Data,args=(destIP,destPort,)) 
19         tr.start()       #打开接受信息的线程,要传入接收对象的名字
20         ts.start()    #打开发送消息的线程,要传入解析出来的地址信息
21         ts.join()
22         tr.join()
23         print("**************************************************")
24         return
25 def recv_Data(user):
26     while True:
27         recvInfo = mysock.recvfrom(1024)
28         (Data,Addr)=recvInfo
29         if Data.decode('gb2312') == 'quit':  #聊天对象发送来内容为quit时,本地
30             Break                     #停止发送,结束本地 接受消息线程
31         print("\nAt [%s]\n%s>>%s"%(time.ctime(),user,Data.decode('gb2312')))
32     print("Receiving data threading over")
33     return
34 def send_Data(destIP,destPort):
35     Token = 1
36     while Token:
37         sendInfo = input("<<")
38         if sendInfo == 'quit':       #本地渴望结束聊聊天时,发送quit,并把标记Token
39             Token=0               #设置为0跳出循环,结束本地 发送消息线程
40         mysock.sendto(sendInfo.encode("gb2312"), (destIP, destPort))
41     print("Send data threading over")
42     return

  我是用多线程实现了用户之间同时收、发信息,重要标记我已经放到了上面的注释里面,这里比较聪明的一点就是双方互发quit,那么双方都会直接结束发送线程、间接结束接受线程。

  退出时:

---->

---->

  最后线程全部退出,主动权交给main函数,选择下一步功能。

  Server2 处理聊天功能

 1 def DealChat(text,ClientAddr):
 2     Cip,Cport=ClientAddr[0],ClientAddr[1]
 3     username=text[2:]
 4     if len(username)!=int(text[1]):
 5         print("Receive data is error!")
 6         return
 7     sql_check="select ip_addr,port from client where username='%s' and state=1"% username
 8     try:
 9         cursor.execute(sql_check)     #查询对象地址信息
10         check_res=cursor.fetchone()
11         db.commit()     
12         cursor.scroll(0,"absolute")
13         if check_res==None:  #说明用户选择的对象不存在或者已经离线
14             error_info=("Sorry,%s might just be off line......"%username).encode()
15             sock.sendto(error_info,ClientAddr)  
16             return
17         (destIP,destPort)=check_res   #构造报文,封装ip地址和port,标志为‘1’
18         payload='1'+hex(len(destIP))[2:]+hex(len(str(destPort)))[2:]+destIP+str(destPort)
19         sock.sendto(payload.encode('gb2312'),ClientAddr)
20     except Exception as e:
21         raise e
22         db.rollback()
23     return

  主程序对聊天的处理仅仅是把用户选择的目标对象的地址从数据库取出封装成报文回馈给用户。

  2.client端传文件功能 上代码

def TransFile():
    global mystate
    if mystate == 0:
            print("You haven't login until now......")
            return 
  select_num=input("Input s means send files,input r means receive files:  " #这一部分是选择文件传输的功能,s代表发送文件,r代表接受文件。
 1  if select_num =='s':   
 2         print("Ready to check online users for you....")
 3         Check()  
 4         send_obj=input("Choose someuser's name you would like to send file:")
 5         payload='5'+str(hex(len(send_obj))[2:])+send_obj #构造报文选择传文件对象
 6         mysock.sendto(payload.encode('gb2312'),sendAddr2)
 7         recvData = mysock.recvfrom(1024)  #获得聊天对象udp的ip、port信息
 8         recv_info = recvData[0].decode('gb2312')
 9         if recv_info[0] != '2': #接受报文出错
10             Return
11 else:
12       destIP=recv_info[3:3+int(recv_info[1],16)]         #提取对象udp socket的ip、port信息
13       destPort=int(recv_info[3+int(recv_info[1],16):])
14       transAddr=(destIP,destPort)            #用本地udp socket给对象的udp socket发消息
15      mysock.sendto(recvData[0],transAddr)  #消息恰是对象的udp ip、port信息 
16     #说明一下,之所以这么做,后面你就会看到;我要在两个用户之间
17     #新建一个tcp socket连接用来传输文件;对象收到上一行代码的内容后,
18     #新建的tcp socket端口绑定udp socket的端口号+1,方便双方处理
19     transAddr=(destIP,destPort+1)      #  对象 tcp's port = udp's port+1
20     time.sleep(2)        #休眠2秒来等待对方刚才发的udp信息并依此新建tcp socket
21     while True:           #循环传多个文件
22       tcpsocket=socket(AF_INET,SOCK_STREAM)    #本地也要建立tcp socket,作为客户端
23       tcpsocket.connect(transAddr)  #此时对象已经创建并且绑定好了tcp socket 的端口
24       try: 
25         filepath = input('Please Enter file(path) to send:\t') #选择发送的文件路径
26          if os.path.isfile(filepath):
27              fileinfo_size=struct.calcsize('128sl')     #定义打包规则
28               fhead=struct.pack('128sl',os.path.basename(filepath).encode(), os.stat(filepath). st_size) 
29               tcpsocket.send(fhead) #文件名+文件大小
30               buf = tcpsocket.recv(1024).decode('gb2312') #接收到报文
31               if buf[0]=='Y':            #表示文件已经存在,开始断点续传
32                  offset=int(buf[1:])  #报文后半部分涵盖已发送文件大小
33                  send_size=offset   #send_size表示已发送的大小
34                  print("Already send %d bytes."%offset)
35                  fo = open(filepath,'rb') 
36                  fo.seek(offset,os.SEEK_SET) #移动偏移指针为原来断点的位置开始读
37                elif buf[0]=='N':       #文件不存在,发送的是新文件
38                  send_size=0        #send_size表示已发送的大小
39                  fo = open(filepath,'rb')             
40               while True:
41                  filedata = fo.read(1024)  #每次发送1Kb的数据
42                  if not filedata:            #文件读出结束,已到末尾
43                      break
44                   tcpsocket.send(filedata)
45                fo.close()                  #发完了就关闭文件,并显示下面的信息
46               print('The file %s send over...\n***********' % os.path.basename(filepath) )
47           else:                 #找不到要啊送的源文件
48              print("No such file!!!")
49              tcpsocket.close()
50       except KeyboardInterrupt:         #用户Ctrl+c终止大文件发送
51          tcpsocket.close()
52          break 

 接收文件功能:

 1 elif select_num == 'r':
 2         (getData,Addr)=mysock.recvfrom(1024)       # mysock is udp
 3         local_info=getData.decode('gb2312')
 4         localIP=local_info[3:3+int(local_info[1],16)]    #提取本地IP、port
 5         localPort=int(local_info[3+int(local_info[1],16):])+1  #tcp's port = udp's port+1
 6         print("local IP:%s,local udp Port:%d"%(localIP,localPort-1))
 7         localsock = socket(AF_INET,SOCK_STREAM)     #新建tcp socket ;localsock is tcp
 8         localsock.setsockopt(SOL_SOCKET,SO_REUSEADDR,1)
 9         localsock.bind((localIP,localPort))  #绑定的端口号是udp端口号+1
10         localsock.listen(5)
11         while True:
12             connection,address=localsock.accept()
13             print('Connected with:',address[0],':',address[1])
14             try:
15                 fileinfo_size=struct.calcsize('128sl')  #接受数据流的大小
16                 buf_fer = connection.recv(fileinfo_size) #buf_fer:收到文件名字和文件大小
17                 if buf_fer:
18                     filename,filesize =struct.unpack('128sl',buf_fer)
19                     filesize=int(filesize)
20                     filename_f = filename.decode().strip('\00')  
21 #去除字符串首、尾的指定字符 这里是 ’\x00‘
22                     file_location = os.path.join('/home/wangyubo/桌面/',('new_'+ filename_f))  #把目录和新文件名合成一个路径
23                     print('Receiving file new name is %s, size is %d' %(file_location,filesize))
24                     recvd_size = 0                      #定义接收了的文件大小
25                     if os.path.exists(file_location):  #断点传输,本存在这个文件
26                         recvd_size=os.stat(file_location).st_size  #目前的文件大小
27                         payload=('Y'+str(recvd_size)).encode('gb2312') #报文标志为’Y’
28                         connection.send(payload) 
29                         file = open(file_location, "ab")  #文件后面补加收到的数据
30                     else:
31                         connection.send("N".encode("utf-8"))
32                         file = open(file_location, "wb") #新建文件并从头写入
33                     while not recvd_size == filesize:
34                         if filesize - recvd_size > 1024:
35                             rdata = connection.recv(1024)
36                             recvd_size += len(rdata)
37                             percent=float(recvd_size*100)/filesize  #实时显示接收到文
38                             print(">>>Receiving %.2f%% of file"% percent) #件的比例
39                         else:
40                             rdata = connection.recv(filesize - recvd_size) 
41                             recvd_size = filesize
42                         file.write(rdata)  #循环写入文件
43                     file.close()
44                     print('%s receive done'%file_location)
45                 connection.close()     #关闭tcp 连接,下次收文件开启新的tcp连接
46             except KeyboardInterrupt:  #中断则退出函数,进入main功能菜单
47                 connection.close()
48                 localsock.close()
49                 break
50     else:
51         print("Invalid!")
52 return

  Server2 处理传文件功能,和处理聊天功能完全一样,只是回馈给客户端的报文标志不是’1’而是‘2’。

0x05 演示

  先开启两个客户端程序,打开mysql随时查询client数据表作为对比:

  此时开启客户端:

  注册

 

  检查数据库:

  服务端回显注册用户信息:

  不妨再注册两个,这里注册不再演示;

  登录

  在客户端输入用户名和密码就能回显登陆成功的信息

 

  下面是server1的用户登录信息提示

  下面是数据库目前状态,可以看到solo用户state为1

 

 注销

  注销后,从左侧的solo的state看到改为0,服务器也有提示,如下图

  退出

   可以看到,退出功能和注销功能很相似,都要先退出当前用户,区别是退出功能结束程序执行,注销功能只是退出当前用户,然后进入主菜单可以选择其他的功能。从下面的代码也可以清晰地看出这个区别:

 

  用户聊天

   双方(wang和bean)都要输入聊天对象,至此才能通信:

         

  传一些消息:

       

  退出聊天界面,返回主功能界面:

       

  传文件

   接收方设置标志为 r

   

  发送方设置标志为 s

  

  发送方需要当前在线的指明用户名,进入下面的状态   

  

  接收文件方连接成功后显示如下

  

  发送方选择路径文件,指定本文件夹下可以不包含路径(本路径文件)

   

  发送方指定文件名,很快发送完毕

   

  接收方开始接收文件:

   

  接收方完成接收文件:

  

  桌面上显示下载的文件夹,这是因为代码制定了下载到桌面上并给文件重命名。  

  

  断点续传文件

  这次传一个大一点的文件:A.pdf,大约50多兆。  

  

  在传输过程中我们发送方Ctrl+C暂停传输文件功能

  

  指定A.pdf后不久中断传送,可以看到才发送了19.34%左右就停止阻塞了,接收方也中断一下下一次继续接收文件。    

  

  桌面上打开属性可以看到只接受的10M左右大小的文件:

  

  继续传送:发送方显示已发送了多大的文件

  

  这和代码吻合:

  

  下面是接收方的信息:

   

  再回到桌面来看:  

  

  完美的实现了断点续传的功能!

猜你喜欢

转载自www.cnblogs.com/Higgerw/p/11200452.html