本学期开了计算机网络课程,期末的课程设计我选了这个题目——基于 LAN 的即时通讯软件,题目就只有这么短,剩下的全部自己发挥,不限平台不限语言。
由于以前自学过 c++网络编程,写了个简易的聊天室(bug 百出),所有刚开始也想用 c++来写,新建了 MFC 项目正在画界面的时候,才想起今时不同往日,我会的语言不止 c++了,还有 java 和 python。最后决定用 python,虽说 java 写的可能以后会更好扩展更好维护一些,但是 python 写起来应该会更加轻松(个人看法)。
本文基于我当时写的课设报告,在之后可能会将其中学到的知识整理成其他的博文,并在此文中列出。
b 站视频已上传:【课设思路分享】基于 LAN 的即时通讯软件
对应 github 库传送门:simuqq
比较长,配合侧边栏目录食用。
效果展示
程序分为服务端和客户端两部分,服务端无图形界面,客户端具有登录界面、主界面以及聊天窗口界面总共三个图形界面。
先开启服务端程序,再打开客户端程序。
客户端的初始界面是登陆界面,在这个界面可以输入用户名、密码,具有“登录”和“注册”两个按钮。
在输入用户名和密码登录之后,会跳转到主页面。
主页面显示账号个人信息,以及当前在线的其他账号的用户名。用户可以双击选择当前在线的其他账号打开聊天窗口进行聊天。
其中一人断开连接之后:
环境
操作系统 |
windows10 |
编辑器(没影响) |
visual studio code |
解释器 |
python3.7.0 |
需求分析
画个用例图先:
客户端的用例有注册、登录、连接到服务端、查看在线的其他客户端以及选择聊天对象。
其中,选择聊天对象进行聊天需要先查看当前有哪些客户端在线,而在这之前需要登录。
代码文件结构
- client.py:客户端业务逻辑代码
- server.py:服务端代码
- gui
- home_page.py:登录后跳转到的主页面
- login_dlg.py:登录界面
- chat_dlg.py:聊天界面
- account_database.json:用于存放注册账号数据的数据文件
- utility.py:存放一些自己写的工具函数
概要设计及对应代码
为了方便阅读,就将代码部分与设计部分放在一起。
类
界面类
图形界面使用的是 python 自带的 tkinter 模块。对每个界面,单独编写一个类,放在单独的模块中,存放在代码根目录下的 gui 文件夹内。而业务逻辑另外编写 client 类和 server 类。
令界面与业务逻辑结合的方式是,在 client 类中初始化界面时,将自身的处理函数作为回调函数传入界面类中,从而使界面的组件与回调函数绑定。
登录界面代码示例
因为界面不是重点,故仅放出登录界面代码,其他两个界面类类似。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68
| ''' 登录模块login_dlg.py 展示登录窗口并实现登录功能 '''
import tkinter as tk import gui.chat_dlg
class LoginDlg(tk.Frame): def __init__(self, loginCallback, regCallback, master=None): super().__init__(master=master) self.master = master self.geometry() self.loginCallback = loginCallback self.regCallback = regCallback self.userName = tk.StringVar() self.password = tk.StringVar() self.createWidgets()
def createWidgets(self): loginLF = tk.LabelFrame(self, text='登录') loginLF.grid(row=0, column=0, sticky=tk.E+tk.W) self.loginLF = loginLF
self.userNameLab = tk.Label(loginLF, text='用户名') self.userNameLab.grid(row=0, column=0) self.userNameEntry = tk.Entry(loginLF, textvariable=self.userName) self.userNameEntry.grid(row=0, column=1, columnspan=2)
self.passwdLab = tk.Label(loginLF, text='密码') self.passwdLab.grid(row=1, column=0) self.passwdEntry = tk.Entry( loginLF, show='*', textvariable=self.password) self.passwdEntry.grid(row=1, column=1, columnspan=2)
self.loginBtn = tk.Button( loginLF, text='登录', command=self.loginCallback) self.loginBtn.grid(row=2, column=1)
self.signupBtn = tk.Button( loginLF, text='注册', command=self.regCallback) self.signupBtn.grid(row=2, column=2)
def test(self): print(self.userName.get()) print(self.password.get())
def geometry(self): self.master.geometry('300x100')
def test2(): pass if __name__ == '__main__': window = tk.Tk() loginDlg = LoginDlg(test2, test2, window) loginDlg.mainloop()
|
客户端类 Client
客户端掌握着界面对象的引用,在初始化它们时,将自身的处理函数传入给它们,以便在触发界面事件时调用。
客户端主要提供了以下方法:
方法 |
简介 |
connect |
连接到服务器 |
login |
登录,需要调用 connect,由服务端进行合法性检测 |
register |
注册,为了简化而直接由客户端写入文件 |
send |
构造消息并发送给服务端 |
recv |
接收消息并解析 |
为了客户端与服务端交流的便利,我自定义了消息格式,所以发送时需要封装,接收时需要解析,下文会讲。
服务端类 Server
服务端没有界面(做了就做不完了),负责接收连接以及转发客户端之间的聊天消息。ip 以及端口是固定的。
数据文件格式
已经注册的账号信息使用 json 文件保存(即account_database.json
),保存格式如下:
1 2 3 4 5 6 7 8 9 10
| { "用户名1": { "password": "密码1", "registerTime": 注册时间1 }, "用户名2": { "password": "密码2", "registerTime": 注册时间2 } }
|
使用用户名作为键,每个用户对应一个密码以及一个注册时间。
自定义消息
(这个是我自己规定的服务端和客户端之间交换信息的格式)
客户端不直接与另一个客户端通信,而是通过服务端转发。
客户端与服务端之间发送规定格式的 json 字符串来交流,此字符串以下称之为“消息”,聊天的文字称作“聊天消息”。此格式解析出来是 python 的一个字典,也就是 json 里面的对象,可以方便地使用键值对来找到需要的字段值。字段如下(不是所有的字段都同时被设置):
字段 |
说明 |
type |
当前消息类型(必选) |
userName |
用户名 |
password |
密码 |
errStr |
错误字符串 |
infoStr |
信息字符串 |
message |
聊天消息 |
data |
传递的数据 |
其中 type 字段的值以及对应的必选字段如下:
- 登录 login:必须设置 userName 和 password,用于客户端发送登录请求以及服务端发送确认;
- 数据刷新 data:必须设置 data,且为字典,用于服务端给客户端发送更新后的当前在线列表;
- 聊天消息 msg: 必须设置 message 和 userName,用于客户端向另一个客户端发送聊天消息时使用,其中 message 是聊天消息的内容。当源客户端向服务端发送此消息时,userName 是目的客户端的用户名,服务端接收到消息之后,将 userName 改为源客户端的用户名,然后转发消息给目的客户端;
- 提示 info: 必须设置 infoStr,发送提示信息
- 错误 err:必须设置 errStr,发送错误信息
注册
注册时客户端读取数据文件并检查注册信息合法性,用户名不可重复,密码可以重复,用户名和密码都是字符串。
当注册信息合法,就组装 json 字符串,并写入数据文件。
注意:这里本来应该是客户端将注册信息发送给服务端,然后服务端修改数据文件的,但是我为了防止自己写不完,就简化了这个流程
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52
| import utility class Client: def register(self): ''' 注册 在填写了用户名和密码之后,如果信息合法,则将信息写入数据文件 :return: 注册成功返回0,失败返回-1 ''' userName = self.gui['loginDlg'].userName.get() password = self.gui['loginDlg'].password.get()
if userName == '': utility.showerror('用户名不能为空') return -1 if password == '': utility.showerror('密码不能为空') return -1
with open(self.dataFile, 'a+') as fp: fp.seek(0) accountStr = fp.read() if accountStr == '': accountData = {} else: fp.seek(0) accountData = json.loads(accountStr)
if userName in accountData.keys(): utility.showerror('该用户名已经被注册') return -1
accountData.update({ userName: { 'password': password, 'registerTime': time.time() } })
utility.showinfo('注册成功!') fp.seek(0) fp.truncate() json.dump(accountData, fp, indent=4, separators=(',', ':'))
return 0
|
这一部分的要点在于,文件中存储的是 json 字符串,不能简单地添加到文件末尾,而是需要将数据先读取出来,添加完数据后,再将整个文件覆盖。
登录
客户端首先尝试连接服务端,如果成功再进行下一步。
请求登录
客户端向服务端发送登录请求消息,并等待服务端的确认消息。
登录请求消息的结构:
字段 |
值 |
type |
login |
userName |
用户名输入框中的值 |
password |
密码输入框中的值 |
1 2 3 4 5 6 7 8 9 10 11 12 13
| class Client: def sendLoginData(self, userName: str, password: str): ''' 发送登录数据 ''' accountData = { 'type': 'login', 'userName': userName, 'password': password } accountStr = utility.dumpJson(accountData) self.send(accountStr)
|
服务端收到登录请求消息之后,检查账号信息的合法性。会向客户端回复两种消息,错误消息或者登录确认消息。
处理登录请求
登录失败
如果账号信息错误,服务端向此客户端发送错误消息,并断开与它的连接。错误消息包含以下字段:
err 消息的结构:
字段 |
值 |
type |
err |
errStr |
错误信息 |
客户端收到此错误消息后,显示警告对话框,并重置 socket。
登录成功
若服务端检测到账号信息无误,则向此客户端发送确认消息,并将它的 socket、地址以及登录时间加入到在线列表中,并向其他在线的客户端发送数据刷新消息(见下文)。
登录确认消息的结构:
字段 |
值 |
type |
login |
userName |
置为空字符串 |
password |
置为空字符串 |
infoStr |
可选,登录成功提示 |
data |
设置为当前在线账户列表 |
这是服务端处理登录请求的代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56
| class Server: def acceptLogin(self, cliSock, cliAddr): ''' 接受已连接的客户端的登录请求 :return: 登录成功返回0,失败返回-1 ''' loginStr = cliSock.recv(self.bufsize).decode() loginDict = utility.loadJson(loginStr)
if not utility.isCorrectMsg(loginDict): errStr = '数据有误,请重新连接' self.closeLink(cliSock, errStr) return -1
cliUserName = loginDict['userName'] cliPassword = loginDict['password'] res = self.checkAccount(cliUserName, cliPassword) if res == -1: errStr = '账号不存在' self.closeLink(cliSock, errStr) return -1 elif res == -2: errStr = '密码错误' self.closeLink(cliSock, errStr) return -1 else: logging.info('用户[userName={}]登录成功'.format(cliUserName)) self.onlineClients.update({ cliUserName: { 'socket': cliSock, 'address': cliAddr, 'loginTime': time.time() } }) self.sendLoginAck(cliSock) return 0 def sendLoginAck(self, cliSock): ''' 登录成功之后向客户端发送确认消息以及当前在线客户端列表 ''' curOnline = self.getCurOnline()
msgDict = { 'type': 'login', 'infoStr': '登录成功!', 'data': curOnline, 'userName': '', 'password': '' }
self.send(cliSock, **msgDict)
|
等待确认
发送了登录请求消息之后,客户端会等待服务端发来的登录确认消息或者错误消息。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
| class Client: def recvLoginAck(self): ''' 等待服务端传回确认 :return: 成功返回0,失败返回-1 ''' res = self.recv() res = utility.loadJson(res) if not utility.isCorrectMsg(res): self.resetSock() return -1
if res['type'] == 'err': utility.showerror(res['errStr']) self.resetSock() return -1 elif res['type'] == 'login': if 'infoStr' in res.keys(): utility.showinfo(res['infoStr']) else: utility.showinfo('登录成功')
if 'data' in res.keys(): self.contactList = res['data']['curOnline'] self.gui['homePage'].refreshList(self.contactList) else: contactList = {}
return 0 else: self.resetSock() return -1
|
客户端登录代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38
| class Client: def login(self): ''' 登录 :return: 登录成功返回0,失败返回-1 ''' userName = self.gui['loginDlg'].userName.get() password = self.gui['loginDlg'].password.get()
if userName == '': utility.showerror('用户名不能为空') return -1 if password == '': utility.showerror('密码不能为空') return -1
res = self.connect() if res != 0: return -1 self.sendLoginData(userName, password)
if self.recvLoginAck() == 0: self.userName = userName self.gotoHomePage() self.recvThread = threading.Thread(target=self.recvLoop) self.recvThread.setDaemon(True) self.recvThread.start() else: return -1
|
登录的流程如下图所示:
客户端界面跳转
登录成功后,会从登陆界面跳转到主页面。
原理是将登录界面隐藏,再显示主界面。
1 2 3 4 5 6 7 8 9 10 11 12 13
| class Client: def gotoHomePage(self): ''' 跳转到主页面 '''
for page in self.gui.keys(): self.gui[page].grid_forget()
self.window.title('SimuQQ主页面') self.gui['homePage'].grid(row=0, column=0) self.gui['homePage'].userName.set(self.userName) self.gui['homePage'].geometry()
|
打开聊天窗口:
1 2 3 4 5 6 7 8 9 10 11
| class Client: def openChatWindow(self, userName): ''' 打开聊天窗口 :param userName: 聊天对象的用户名 ''' self.chatWith = userName self.gui['chatDlg'].grid(row=0, column=0) self.chatWindow.title('[{}]向[{}]发起的聊天'.format(self.userName, userName)) self.chatWindow.deiconify()
|
发送聊天消息
用户在客户端的在线列表中双击选择一个在线客户端,会打开对选择对象的聊天窗口。
用户在输入框中输入聊天消息并点击发送按钮后,客户端将会构建并发送 msg 消息给服务端,该消息的内容如下:
msg 消息的结构:
字段 |
值 |
type |
msg |
message |
需要发送给聊天对象的聊天消息 |
userName |
聊天对象的用户名 |
服务端在收到客户端的 msg 消息后,将 userName 字段修改为发送端的用户名,并转发给目的端。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| class Client: def sendChatMsg(self): ''' 发送聊天消息 ''' chatMsg = self.gui['chatDlg'].getInputContent()
msgDict = { 'type': 'msg', 'userName': self.chatWith, 'message': chatMsg } msgStr = utility.dumpJson(msgDict) self.send(msgStr) outputContent = '[{}]{}\n{}'.format( self.userName, time.strftime('%Y/%m/%d %H:%M:%S'), chatMsg)
self.gui['chatDlg'].addOutputContent(outputContent) self.gui['chatDlg'].clearInputContent()
|
客户端消息处理
客户端在登录成功后,开启消息接收线程,它的线程体是一个无限循环,并将其置为守护线程(Deamon Thread),在所有前台线程结束之后,消息接收线程也随之结束。
在没有注意到这一点前,我调试了这个 bug 很久——关闭窗口会无响应,后来才知道不是 tkinter 的问题,而是我开的这个线程没有随之关闭。
客户端主要会收到两种消息,数据刷新消息和聊天消息。同样的,这里本来还应该处理 err 消息以及 info 消息的,担心做不完就简化了。
数据刷新消息
在服务端接收一个新的连接时,或是服务端检测到一个旧有连接断开时,会向当前在线的客户端发送一个数据刷新消息,该消息包含以下字段:
data 消息的结构:
字段 |
值 |
type |
data |
data |
当前在线账号的用户名的列表 |
客户端收到此消息时,调用界面类的对应方法刷新主页面的在线列表。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| class Client: def recvLoop(self): ''' 接收消息的循环 ''' while True: msgStr = self.recv() msgDict = utility.loadJson(msgStr) if not utility.isCorrectMsg(msgDict): continue
if msgDict['type'] == 'data': self.contactList = msgDict['data']['curOnline'] self.gui['homePage'].refreshList(self.contactList) if msgDict['type'] == 'msg': self.openChatWindow(msgDict['userName']) outputContent = '[{}]{}\n{}'.format( msgDict['userName'], time.strftime('%Y/%m/%d %H:%M:%S'), msgDict['message'])
self.gui['chatDlg'].addOutputContent(outputContent)
|
服务端消息处理
接下来是比较核心的部分。
在编写客户端时,为了专注于客户端的编写,对于服务端,我采用的是比较简单的无限循环:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| class Server: def acceptLoop(self): ''' 接受连接的线程循环
刚开始的时候测试客户端用的, 客户端的登录测试完毕之后,将其中的代码提取出来封成另一个函数acceptLogin(), 供selectLoop使用 ''' while True: logging.info('正在等待新的连接') cliSock, cliAddr = self.serSock.accept() self.acceptLogin(cliSock, cliAddr)
|
客户端大致成型之后,我开始编写服务端的这一部分。
我想起来之前在编写 c++聊天室的时候,用到了一个事件模型,可以解决以上线程循环遇到的问题。
上面这个写法会出现的问题,以前我就遇到过。
如果服务端想要监听多个客户端发送过来的聊天消息,第一种方法是遍历每个客户端,recv 每个客户端(将客户端 socket 改成非阻塞的就行);第二种方法是为每个客户端建立单独的接收消息线程。
这两个方案其实都不太好。后来我找到了一个叫做事件选择模型的东西(WSAEventSelect
),解决了一部分问题,当时知识还是太浅薄,不能完全理解那东西,所以还是写出了一堆 bug。
现在回想起来,python 里面应该也有类似的东西吧?我就记着个 select 了,一搜,还真是叫做 select。
找到可用的资料好像并不太多,其中一个对我很有用的文章的链接是这个:python Select 模块简单使用
后来翻了一下文档,找到了关于 select 的英文原版简介
我简单描述一下我在这个项目里面是如何使用 select 模型的:
设置三个需要处理消息的队列,分别存放所有的 socket(包括服务端 socket 和客户端 socket)、用于存放存在待处理消息的 socket 的等待队列、需要检查错误的 socket 的队列。
select 函数接收上述三个队列,并在阻塞 timeout 时间后返回三个队列,分别是可读取队列、可写入队列和错误队列。
可读取队列中的 socket 是已经接收到消息的 socket,即接收缓冲区中存在消息,需要处理。如果是服务端 socket,表明有新的客户端连接请求到达,对连接请求进行处理;如果是客户端 socket,表明有已经连接的客户端发送消息过来,先将它们放入对应的消息队列中,并将它们加入到第二个监听队列即等待消息处理的队列。
可写入队列中的 socket 是从等待消息处理的队列中选择出目前能够接受消息、即接收缓冲区可用的 socket。遍历这个队列,对其中的 socket 进行消息处理,处理完毕后删除它的消息队列,以及将它移出等待队列。
错误队列存放从需要检查错误的 socket 队列中选择出的出错的 socket,在本项目中将需要检查设置为存放所有 socket 的队列,即检查所有的 socket。遍历此队列,将错误的 socket 移除。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73
| class Server: def selectLoop(self): ''' 使用select函数来进行处理的循环 ''' readList = [self.serSock] writeList = [] message_dict = {} i = 0
logging.info('服务器已经启动,正在等待客户端的连接') while True: logging.debug('循环数:'+str(i)) i += 1 readableList, writableList, exceptionList = select.select( readList, writeList, readList, 1)
for sock in readableList:
if sock is self.serSock: cliSock, cliAddr = sock.accept() if self.acceptLogin(cliSock, cliAddr) == 0: readList.append(cliSock) message_dict[cliSock] = [] self.refreshCurOnline() else:
try: data = sock.recv(self.bufsize) except: readList.remove(sock) del message_dict[sock]
logging.info('客户端[userName={}]断开了连接'.format( self.getUserNameBySock(sock))) self.closeLink(sock) else: if not data: readList.remove(sock) del message_dict[sock] self.closeLink(sock) print('客户端[{}]断开了连接'.format( self.getUserNameBySock(sock))) else: dataStr = data.decode() message_dict[sock].append(dataStr) writeList.append(sock)
for sock in writableList: while len(message_dict[sock]) > 0: dataStr = message_dict[sock][0] del message_dict[sock][0] self.addressMsg(sock, dataStr)
writeList.remove(sock)
for sock in exceptionList: readList.remove(sock)
|
待我更加理解这个东西,可能会回来补充完善这个部分。
小结
以上便是我的计算机网络课设的核心思路以及核心代码了,其他未列出的方法,读者看方法名字大致也能猜到它们的作用,就不浪费篇幅去说了。
本文耗费 5 个小时完成(结合课设报告)。
如有错漏欢迎在下方评论区指出。