【课设总结】基于LAN的即时通信软件

本学期开了计算机网络课程,期末的课程设计我选了这个题目——基于 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.pack()
# self.grid(row=0,column=0)
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:
# 使用a+方式打开,防止文件内容被覆盖
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
'''
# 获取客户端提交的账号密码,客户端以json字符串的形式发送过来
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, # 客户端socket
'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() # 重启socket
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:
# 如果不是err消息也不是确认消息,则登录失败
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()
# self.chatWindow.mainloop()

发送聊天消息

用户在客户端的在线列表中双击选择一个在线客户端,会打开对选择对象的聊天窗口。

用户在输入框中输入聊天消息并点击发送按钮后,客户端将会构建并发送 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 = {} # 存储消息用的字典,键为socket,值为消息列表
i = 0

logging.info('服务器已经启动,正在等待客户端的连接')
while True:
logging.debug('循环数:'+str(i))
i += 1
# select函数阻塞timeout时间,从参数的三个列表中,选择出此时可读取、可写入、出现错误的元素返回
readableList, writableList, exceptionList = select.select(
readList, writeList, readList, 1)

# 1. 遍历当前可读取的socket
for sock in readableList:

if sock is self.serSock:
# 如果是服务端socket,那么就是有客户端来连接了
cliSock, cliAddr = sock.accept()
if self.acceptLogin(cliSock, cliAddr) == 0:
# 如果登录成功
readList.append(cliSock) # 将新的客户端socket加入监听列表
message_dict[cliSock] = [] # 为新的socket创建消息列表
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)

# 2.处理待回复的消息
for sock in writableList:
while len(message_dict[sock]) > 0:
dataStr = message_dict[sock][0] # 取出消息队列中第一个消息
del message_dict[sock][0]
self.addressMsg(sock, dataStr) # 处理消息
# 测试代码:测试消息处理是否可用
# sock.sendall(('echo:'+dataStr).encode())

# 将消息队列中所有消息处理完毕,则将它从待回复队列中删除
writeList.remove(sock)

# 3.处理出错的socket
for sock in exceptionList:
readList.remove(sock)

待我更加理解这个东西,可能会回来补充完善这个部分。

小结

以上便是我的计算机网络课设的核心思路以及核心代码了,其他未列出的方法,读者看方法名字大致也能猜到它们的作用,就不浪费篇幅去说了。

本文耗费 5 个小时完成(结合课设报告)。

如有错漏欢迎在下方评论区指出。

【课设总结】基于LAN的即时通信软件

https://yxchangingself.xyz/posts/IMS_base_on_LAN/

作者

憧憬少

发布于

2020-01-02

更新于

2020-01-02

许可协议