这是我Python课的课程设计,按理说这种聊天程序应该用UDP的,但是我的课程设计题目是TCP的,所以就用TCP写了
由于是课程设计,所以写的也没有那么复杂,后面有时间了再添加新功能,如果有什么建议可以联系我(邮箱cytcyt123@163.com),第一次发博客,请各位大神多多指教,有什么需要改进(包括但不限于程序,别的问题也可以指出来,我会虚心请教的),主要实现了多个客户端之间能互发消息,而且如果对方没在线,待其上线时服务器会自动发给他,所有的消息都会保存在数据库
使用了面对对象思想,服务器是数据(操纵数据库),UI,逻辑分离,因为客户端的数据都是从服务器来的,所以是数据和逻辑在一块,是UI和逻辑分离
总体说下程序,不明白的地方请评论,我再补充
百度云 链接:https://pan.baidu.com/s/1HievjAta3du6yclIWXMDYg 提取码:jpj7
Github: https://github.com/yougtao/PyChat
server.py运行一个, client.py可以运行多个, 要注意修改端口; python3环境,下载好相应的库, mysql数据库(后面有介绍)
下载不了联系我邮箱:cytcyt123@163.com
目录
ServerUI类,只负责显示界面,处理显示消息,刷新联系人列表等
先上运行界面:
一个服务器和两个客户端,模仿了Tim的界面,服务器绿背景是已经上线的,灰色为离线
聊天窗口内也是跟以前的QQ一样的,原谅我气泡消息形式做不出来,不能与时俱进
有时间了把头像啥的也弄出来,早日打败腾讯哈哈哈
橙色是这个人有新消息来了!
说一下程序的一些要点:
1. 客户端与服务器之间发送的所有数据格式都是字典形式:{"type":1, "from":12, "to":23, "data":"要发的东西"} type是类型,to是接收人
2.type字段,服务端收到客户端发的数据后根据type字段来判断这是什么数据,偶数的都是对应的服务器返回的数据:
1是登录相关,例如要发送登录请求,判断登录账号密码,hello消息等;2是服务器的回应
3是请求数据;4是服务器返回的结果
11是给联系人发的消息,客户端发到服务器;4是服务器发到对应的客户端
13是给群联系人发的消息;14是服务器发给客户端
服务器有个接收socket,用来接受某个用户发来的消息,然后进行处理(该转发转发,该显示显示等)
每个用户也会建立一个接收的socket,用来接受服务器发来的消息,然后也是该显示显示等(这里不要把程序上的服务器客户端和socket层面上的服务器客户端混为一谈,也就是服务器和客户端互为 socket服务器socket客户端,要是还不明白的发评论提问)
每个用户在登录时会将自己的IP地址和接受socket的端口号发送给服务器保存起来,服务器在给客户端发送数据的时候就根据这个来和客户端(刚刚说了,客户端会建立一个接收socket的)建立连接(可怕的TCP,还要先三次握手建立好连接才能发送,要是用UDP多好,直接发,就怪我在抢题目的时候没抢到吧)
服务器程序:
服务端程序,一共三个类:数据类(Database),UI类(ServerUI),逻辑类(Server)
Database类,获取数据修改数据都是它的活了
用了pymysql库,我这里连接 host="127.0.0.1" user="root",password="",database="python"
先看下数据库(这个数据库建的不是很好,因为后面代码重写了一次,所以有些字段没用了,懒得没有删)
数据库名:python,四个表:user用户表,group群表,group_member群成员表,message消息记录表
user表 | group表 | group_member表 | message表 | |||||||
id(主键) | int | id(主键) | int | id | int | id(主键) | int | |||
nickname | varchar(50) | name | varchar(50) | memberID | int | source | int | |||
status | int | target | int | |||||||
ip | int | time | datatime | |||||||
port | int | message | varchar | |||||||
status | int |
里面建了几个函数,供逻辑类(Server类)来调用,挑几个说一下
像这个addMessage()函数,将消息添加到数据库,source:发送人的id,target:接受人的id,time:发送时间(这里记录的是服务器接受到的时候的时间),message:消息内容,status:1代表已经发送给接收人了,0代表接收人还没上线(上线时会自动发给他
)
# 添加消息
def addMessage(self,source,target,time,message,status)
像这个getUserList()函数,因为服务器会显示用户列表,所以这里建了这么个函数,逻辑类只需要调用这个函数就能获取到用户列表,然后交给UI类去显示,返回的格式是字典数组(不知道python字典是啥的去百度)
像这样 [{"id"}:36,"name":"昵称","status":-1,"ip":0,"port":0}, {"id"}:36,"name":"昵称","status":-1,"ip":0,"port":0}]
# 获取用户列表
def getUserList(self)
# 获取群列表
def getGroupList(self)
getContacts()函数:这个是取出来发给客户端的,因为里面的字段不一样所以和上面的分开了
# 获取contacts列表
def getContacts(self)
其他的像 findIndex() findName() findSock() isOnline()等都有注释,而且看名字就明白
ServerUI类,只负责显示界面,处理显示消息,刷新联系人列表等
一共有4个函数,具体的实现等后面补充的时候再具体分析,先说说各个函数的作用
__init__()函数:初始化函数主要的界面都在这里创建,具体的看代码
setUserList(self,users)函数:设置左边的用户列表,逻辑类获取到所有的用户信息,然后调用这个函数来显示
setUserStatus(self,index,status)函数:某个用户在线离线时逻辑类会调用这个函数,改变用户列表的项背景色(status=0灰 1绿),注意index不是用户的id,二是用户列表的索引(用户列表的顺序会和逻辑类里保存用户数组的顺序一致)
showLog(self, text, type = 0)函数:显示日志消息,也是逻辑类来调用,将消息显示在右边窗口上,text要显示的文本,type设置颜色的(0=默认黑色,1=绿色,2=橙色,3=红色)
Server逻辑类,关键了
会开两个线程,一个是用来刷新用户状态的,每个用户上线时都会给其设置一个time=10,每秒会减1,当小于0时就将其设置为离线了,当然肯定不能每个用户只能登录10秒啊,每个客户端在登录成功后每隔5秒会发送一个hello信息,这时候就会将time重新设置为10秒,这样就可以一直在线了,这样当某个客户端意外退出时,服务器就会自动将其设置为离线了
另一个线程是接受客户端数据的线程,接受好后会根据发送的数据的type字段进行判断处理,具体的后面说
refreshUserStatus() 函数:这是第一个线程所调用的函数,这个刷新用户状态的线程具体怎么处理的都在这个函数里,待会详细说
receiveThread()函数:这是第二个线程,sock,addr = self.receiveSock.accept(),在accept()客户端的连接后会再开一个线程来处理,为什么还要再开呢,主要是这样就会立即继续循环等待accept()接受其他客户端的请求,而不必等处理完这个客户端后再处理下一个客户端,如果处理的比较快还好,要是时间久了那个客户端会等不及呀;
# 接受消息线程
def receiveThread(self):
try:
self.receiveSock = socket.socket(socket.AF_INET,socket.SOCK_STREAM)
self.receiveSock.bind((self.HostIP, self.HostPort))
self.receiveSock.listen(32) #允许最大连接数为16
self.buffersize = 1024
serverUI.showLog("Server准备就绪,IP=" +self.HostIP + " Port=" + str(self.HostPort) +" listen=32 maxsize=1024", 2)
except:
serverUI.showLog('创建接收socket失败!', 3)
return False
while True:
try:
sock,addr = self.receiveSock.accept()
t = _thread.start_new_thread(self.receive,(sock,addr))
except:
serverUI.showLog('接收数据失败!', 3)
sock.close()
reveive()函数:注意是指的我自己定义的这个函数,不是socket的那个recv()函数,不要混了
从Python3开始要求sock收发消息的时候要发送字节流不能直接发字符串,就因为这个Debug了好久就是不知道哪里的事
做一个循环来接受用户发的数据,sock.recv(1024).decode(encoding = "utf8") ,接受数据(字节流),用decode解码成字符串,因为发送的时候就是json字符串(json不知道的百度一下,很简单)形式,然后json.load()还原成python数组形式;
根据type字段判断这是客户端发来的什么消息,type=1,登录相关的信息,交给loginHandle()函数处理;type=3,客户端请求数据,这里只写了一个联系人列表,本来还想加个请求历史消息记录的;type=11or13,客户端给好友或者群发送消息,交给handleMessage()函数去处理
# 接受sock处理
def receive(self,sock,addr):
try:
while True:
data = sock.recv(1024).decode(encoding = "utf8")
if not data:
break
msg = json.loads(data)
if msg['type'] == 1: # 登录
self.loginHandle(msg,sock)
elif msg['type'] == 3: #请求信息
if msg['data'] == "contacts": # 请求联系人列表
contacts = self.database.getContacts()
if self.sendData(4,msg['from'],contacts,{"code":"contacts"}):
serverUI.showLog('发送联系人列表失败!', 3)
elif msg['type'] == 11 or msg['type'] == 13:
self.handleMessage(msg) #消息处理
except:
serverUI.showLog('客户端关闭了连接!', 3)
pass
finally:
sock.close()
return
loginHandle(msg,sock)函数:这里牵扯到一个是login,一个是hello,客户端先发送login登录,成功后每隔5秒会发送一个hello消息,前面已经说了服务会给每个客户端设置一个超时值,收到客户端发来的hello后就会给其time重新设置为10,如果隔了10秒还未收到客户端的hello消息就讲其强制下线
具体分析下这个函数,先找用户是不是存在,isLogin代表是否登录,time大于0就是登录
如果发送的事hello并且服务器记录的确实是已经在线了,调用online()函数,这个函数就是将其time重新设置为10,并将数据库中对应的用户status字段设置为1;接下来是查找此用户的name(接受的时候只接收了该用户的id,就是有QQ号了,现在要找他的昵称),找到name后调用UI类的showLog()函数,在服务器的日志窗口中显示,接下来是将用户的time重新设置为10,然后接收他发来的ip和port,以便服务器给该客户端发送消息时使用(因为服务器的ip和port是固定的,客户端有很多个,服务器并不知道他的ip和port信息);之后就将联系人列表和未读的消息发送给该客户端(自行脑补QQ),time.sleep(1)是程序暂停1秒,要让客户端消化消化啊,不要发的这么快嘛(不要删掉,因为客户端这个时候正在刷新联系人列表,联系人还没出来怎么接受消息啊)
# 处理登录
def loginHandle(self,msg,sock):
id = msg ['from']
index = self.database.findIndex(id)
if index < 0 :
return False
isLogin = self.database.users[index]['time'] > 0
if msg['data'] == "hello" and isLogin:
self.database.online(id) #修改数据状态
return
name = self.database.findName(id)
if isLogin:
serverUI.showLog('用户('+name+')重新上线!', 1)
else:
serverUI.showLog('用户('+name+')已经上线!', 1)
serverUI.setUserStatus(index,10) # 更新用户状态
self.database.users[index]['ip'] = msg['ip']
self.database.users[index]['port'] = msg['port']
self.database.online(id) #修改数据状态
# 发送联系人列表
contacts = self.database.getContacts()
if not self.sendData(4,msg['from'],contacts,{"code":"contacts"}):
serverUI.showLog('发送联系人列表失败!', 3)
time.sleep(1)
if msg['data'] == "login":
msgs = self.database.getUnreadMessage(id)
self.sendData(12, id, msgs)
sendMessage()函数:说处理消息之前先说说这个函数,服务器给客户端发数据只需要调用这个函数就行了
说说服务器给客户端发的数据的格式,还是这个样子 {"type":12, "to":23, "data":"要发的东西"} ,只不过data有点不同,客户端给数据库发的data就是消息文本本身,但是服务器给客户端发还要加上time,然后可能还一次发多条,所以data就用了一个数组;
变成了 {"type":1, "to":23, "data":data是个数组}
data数组格式 ["from":谁的,"fromName":"具体谁发的,名字", time:"发送时间", "msg":"这才是消息内容"]
注意from和fromName不一定是对应的关系,给好友的时候是对应的;给群发的时候,from是指的哪个好友或者群(好友 消息就是QQ号,群消息就是群号);而fromName是发送人的名字,不管是他发的是好友消息还是群消息(如果是群里发的那也是在群里发消息的这个人的名字而不是群名字,可以打开QQ群仔细想想)
讲讲这个函数,findSock()是查找该人的ip和port,因为客户端会建立一个接收的套接字,所以这里用connect()连接就行了,json.dumps()先将字典msg转为json字符串,bytes()函数将字符串转为字节流,dict(msg,**extra)是将这两个字典拼接在一起
def sendData(self,type,to,data,extra={}):
if not data:
return False
ip,port = self.database.findSock(to)
try:
sendSock=socket.socket(socket.AF_INET,socket.SOCK_STREAM)
sendSock.connect((ip, port))
#格式化数据并发送
msg = {"type":type, "to":to, "data":data}
json_string = json.dumps(dict(msg,**extra))
sendSock.send(bytes(json_string, encoding = "utf8"))
sendSock.close()
return True
except:
sendSock.close()
return False
handleMessage()函数:这个是处理客户端发送的好友或者群消息,这两个处理稍微有点不同,所以用分开判断
theTime记录下当前时间,fromName找到该发送人的名字,这里就没有判断是否已经存在了,因为已经登录了嘛
如果type=11:找到接收人的名字()因为要在服务器的日志窗口中显示嘛),然后组装好发给接收人的数据,判断该人是否在线,如果在线给他发过去,sendData()函数发送成功会返回True,然后将该消息加入数据库,isSendFlag是代表该消息是否已经成功发给了接收人
如果type=13:跟type=11一样先在日志窗口中显示(不过这里查找的是群名了),然后要找到该群的所有成员并将接收人除去(这个不难理解吧),作个循环给群成员除了自己都发过去
# 处理消息
def handleMessage(self,recvMsg):
msgType = recvMsg['type']
theTime = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
fromName = self.database.findName(recvMsg['from'])
if msgType == 11: # 单发消息
toName = self.database.findName(recvMsg['to'])
headInfo = fromName + ' => ' + toName + ' at '+theTime+' : ';
serverUI.showLog(headInfo + recvMsg['data'])
# 如果在线直接发送,否则添加未读消息
data = [{"from":recvMsg['from'], "fromName":fromName, "time":theTime, "msg":recvMsg['data']}]
isSendFlag = 0
if self.database.isOnline(recvMsg['to']):
if self.sendData(12,recvMsg['to'],data):
isSendFlag = 1
# 加入数据库
self.database.addMessage(recvMsg['from'],recvMsg['to'],theTime,recvMsg['data'],isSendFlag)
elif msgType == 13: # 群发消息
toName = self.database.findGroupName(recvMsg['to'])
headInfo = fromName + ' =>> ' + toName + ' at '+theTime+' : ';
serverUI.showLog(headInfo+recvMsg['data'])
# 循环处理
users = self.database.getGroupMember(recvMsg['to'])
users.remove(recvMsg['from'])
for u in users:
data = [{"from":recvMsg['to'], "fromName":fromName, "time":theTime, "msg":recvMsg['data']}] # 此处to是目的地
isSendFlag = 0
if self.database.isOnline(u):
if self.sendData(14,u,data):
isSendFlag = 1
# 群发消息不加入数据库
#self.database.addMessage(recvMsg['from'],u,theTime,recvMsg['data'],isSendFlag)