题目要求
利用socket和多线程,实现支持多人对话的聊天室。具体地,实现Manager和Chatter 两个类,Chatter只需和Manager之间建立一对一联系,而Manager则负责广播或转发所有用户的消息。相关要求如下:
- 实现Manager类, 服务器,管理成员进入和离开聊天室,接收成员消息并广播
- 实现Chatter类, 用户, 向管理员发送加入和退出请求,发送和接收消息
- Manager类使用多线程服务多个用户
- Chatter用户发送和接收消息需要依赖不同线程进行
- Manager类具备定向转发功能,比如Chatter可以在消息中通过@指定特定用户,这样Manager将仅转发给被指定用户。
- Chatter在离开时,自动保存聊天记录到硬盘(包括时间、发信人,信息)。
- Manager也应保存所有聊天室记录到硬盘。
任务要求
- Manager 类
- 成员进入聊天室: 接收消息
- 成员退出聊天室: 接收消息
- 定向转发: 以@开头的消息定向转发给对应的用户
- @ + 用户名
- 聊天记录保存:
- 退出聊天室之前保存所有聊天信息
- Chatter 类
- 定向转发
- 查看所有成员
- 退出聊天室时保存本地记录
- 接收与发送依赖不同进程进行
- 如何能避免出现
input()
阻塞了其他用户信息的显示的问题?- 可以使用GUI界面,将用户的信息输入过程和其他用户的信息显示分隔开,避免出现上述问题
客户端
登陆GUI
def welcome(self):
# 创建登陆界面
welcome = Tk()
welcome.title('🎉欢迎使用易聊聊天室 - Welcome to YChat!')
welcome.geometry('700x450')
leftFrame = Frame(welcome)
rightFrame = Frame(welcome)
leftFrame.grid(column=0, row=0)
rightFrame.grid(column=1, row=0)
photo = PhotoImage(file="bg.png")
theLabel = Label(leftFrame,
image=photo, # 加入图片
compound=CENTER, # 关键:设置为背景图片
fg="white") # 前景色
theLabel.pack(pady=10)
nameLabel = Label(rightFrame,
text='请输入你的昵称:',
font=('PingFang HK', 14),
height=1,
fg='black')
nameLabel.grid(row=0, sticky=W)
usernameEntry = Entry(rightFrame,
font=('PingFang HK', 20),
width=17, )
usernameEntry.grid(row=1, sticky=W, pady=10)
ipLabel = Label(rightFrame,
text='请输入聊天室 IP:',
font=('PingFang HK', 14),
height=1,
fg='black')
ipLabel.grid(row=2, sticky=W)
ipEntry = Entry(rightFrame,
font=('PingFang HK', 20),
width=17, )
ipEntry.grid(row=3, sticky=W, pady=10)
portLabel = Label(rightFrame,
text='请输入端口号:',
font=('PingFang HK', 14),
height=1,
fg='black')
portLabel.grid(row=4, sticky=W)
portEntry = Entry(rightFrame,
font=('PingFang HK', 20),
width=17, )
portEntry.grid(row=5, sticky=W, pady=10)
def login():
global user, IP, PORT
user = usernameEntry.get()
IP = ipEntry.get()
PORT = portEntry.get()
if not user:
tkinter.messagebox.showwarning('⚠️输入错误!', message='用户名为空!')
else:
if not IP:
tkinter.messagebox.showwarning('⚠️输入错误!', message='IP 地址为空!')
else:
if not PORT:
tkinter.messagebox.showwarning('⚠️输入错误!', message='端口号为空!')
else:
welcome.destroy()
button = Button(rightFrame,
text='进入聊天室',
font=('PingFang HK', 14),
height=2,
width=12,
command=login)
button.grid(row=6, pady=15)
button = Button(rightFrame,
text='退出',
font=('PingFang HK', 14),
height=2,
width=12,
command=exit)
button.grid(row=7)
welcome.mainloop()
聊天界面
# 聊天室 GUI
root = Tk()
root.geometry('800x600')
root.title('💬聊天室 - YChat - 用户: {}'.format(self._user))
root.resizable(0, 0)
# 消息栏
listbox = ScrolledText(root,
font=('PingFang HK', 16))
listbox.place(x=20,
y=20,
width=580,
height=380)
listbox.tag_config('tag1',
foreground='red',
backgroun="yellow")
listbox.insert(tkinter.END, '欢迎进入群聊,大家开始聊天吧!\n', 'tag1')
# 输入框
INPUT = StringVar()
INPUT.set('')
entryIuput = Entry(root,
width=120,
textvariable=INPUT,
font=('PingFang HK', 16))
entryIuput.place(x=20,
y=420,
width=580,
height=160)
# 在线用户列表
listbox1 = Listbox(root,
font=('PingFang HK', 14))
listbox1.place(x=620,
y=20,
width=160,
height=380)
sendButton = tkinter.Button(root,
text="发 送",
anchor='n',
command=sendmsg,
font=('PingFang HK', 18), bg='white', pady=12)
sendButton.place(x=620,
y=470,
width=160,
height=60)
# 首先启动接收进程
q = Thread(target=receive)
q.start()
root.mainloop()
save_log(self._filepath, CHARTLOG)
self._client.close()
发送消息
- 我们定义信息格式如下:
<user>小米</user>
<time>2021-12-29 20:33:14.066927</time>
<msg>大家好!</msg>
def sendmsg():
msg = entryIuput.get()
now_time = datetime.datetime.now()
msg_cont = """\n<user>{}</user>\n<time>{}</time>\n<msg>{}</msg>\n""". \
format(self._user, str(now_time), msg)
self._client.send(msg_cont.encode('utf-8'))
INPUT.set('')
接收消息
- 接收消息并显示消息,这里我们将消息分为三种类型
- 警报消息
- 普通消息
- 本人消息
def receive():
global fg, bg
while True:
msg = self._client.recv(BUFFERS).decode('utf-8')
print(msg)
print('\n')
try:
uses = json.loads(msg)
listbox1.delete(0, tkinter.END)
listbox1.insert(tkinter.END, "当前在线用户")
listbox1.insert(tkinter.END, "------Group chat-------")
for x in range(len(uses)):
listbox1.insert(tkinter.END, uses[x])
users.append('------Group chat-------')
except:
user = re.findall(r'<user>(.+)</user>', msg)
message = re.findall(r'<msg>(.+)</msg>', msg)
time = re.findall(r'<svtime>(.+)</svtime>', msg)
save_log(self._filepath, msg)
user = user[0]
message = message[0]
time = time[0]
listbox.tag_config('tag2',
foreground='white',
backgroun='blue')
listbox.tag_config('tag3',
foreground='red',
backgroun='yellow')
listbox.tag_config('tag4',
foreground='black',
backgroun='white')
outputmsg = '{}\n{}: {}\n'.format(time, user, message)
if user == self._user:
listbox.insert(tkinter.END, outputmsg, 'tag2')
elif user == '系统消息':
listbox.insert(tkinter.END, outputmsg, 'tag3')
else:
listbox.insert(tkinter.END, outputmsg, 'tag4')
listbox.see(tkinter.END)
self._client.connect((self._ip, self._port))
user_info = '<user>{}</user>'.format(self._user)
self._client.send(user_info.encode('utf-8'))
文件保存
def save_log(path, content):
with open(path, 'a', encoding='utf-8') as f:
f.writelines(content)
f.close()
在运行过程中,每接收到一条消息,便写入一条消息:
使用的库和全局变量
import os
import json
import datetime
from socket import *
from tkinter import *
import tkinter.messagebox
from threading import Thread
from tkinter.scrolledtext import ScrolledText
PATH = '/Users/indecreasy/Desktop/212_MPD/P13/'
LOCALHOST = '127.0.0.1' # LOCALHOST IP 号,用于在本地模拟服务端和客户端
PORT = 4399
users = []
BUFFERS = 1024
MAXC = 64
CHARTLOG = []
服务器端
- 采用队列来管理信息的发送顺序;
- 服务器完成信息的接收、处理和转发;
- 重点解决@转发的问题
- 接收和转发采用两个线程来完成
主要部分
- 启动了播报线程,之后每接收到一个新链接,便启动一个监听线程
def __init__(self, ip, port):
self._ip = ip
self._port = port
self._filepath = '{}/{}.txt'.format(PATH, 'server')
file = open(self._filepath, 'w')
file.close()
self.launch()
def launch(self):
server = socket(AF_INET, SOCK_STREAM)
server.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1) # 端口释放后马上可以被重新使用
server.bind((self._ip, self._port))
server.listen(MAXC)
print('Server is started...')
q = threading.Thread(target=self.senddata)
q.start()
while True:
# 接受连接后,启动一个线程处理该连接,主线程继续处理其他连接
conn, addr = server.accept()
t = Thread(target=self.speak, args=(conn, addr))
t.start()
server.close()
监听线程
- 此线程同时完成在线用户的管理
def speak(self, conn, addr):
user_info = conn.recv(BUFFERS).decode('utf-8') # 接收自动播报的用户名
# 提取用户名
# 正则表达式匹配, 提取用户名
nameMatch = re.match(r'^<user>', user_info)
if nameMatch:
pattern = re.compile('<user>(.+)</user>')
username = pattern.findall(user_info)
username = username[0]
user_list.append((username, conn, addr))
online_users = onlines()
self.load(online_users, addr, flag='users')
boarding = '{} ({} - {}) 进入了聊天室'.format(username, addr[0], addr[1])
print(boarding)
try:
while True:
msg = conn.recv(BUFFERS).decode('utf-8')
server_time = datetime.datetime.now()
msg = msg + '<svtime>{}</svtime>\n'.format(str(server_time))
self.load(msg, addr, flag='msg')
conn.close()
except:
# 用户断开连接
j = 0
for user in user_list:
if user[0] == username:
user_list.pop(j) # 服务器段删除退出的用户
break
j = j + 1
online_users = onlines()
self.load(online_users, addr, flag='users')
conn.close()
- 统计在线人数:
def onlines(): # 统计当前在线人员
online = []
for i in range(len(user_list)):
online.append(user_list[i][0])
return online
播报线程
- 重点是在此处完成个别用户的转发
- 同时设置了警报信息
- 针对被私聊用户的提示信息
- @用户名 不存在后的警告信息
def senddata(self):
# 将信息和用户列表转发给各个客户端
global raw_conn
while True:
if not messages.empty():
message = messages.get()
content, addr, flag = message
if flag == 'msg':
save_log(self._filepath, content)
# 判断原语句中是否有@
atMatch = re.search(r'<msg>(@.+) ', content)
if atMatch:
pattern = re.findall(r'@(.+) ', content)
user = pattern[0]
raw_user = re.findall(r'<user>(.+)</user>', content)
raw_user = raw_user[0]
fflag = 0
for i in range(len(user_list)):
if user_list[i][0] == user:
warning = '<user>系统消息</user>\n<time>系统消息</time>\n<msg' \
'>有人对您发送了私聊信息!</msg>\n<svtime>系统消息</svtime> '
user_list[i][1].send(warning.encode('utf-8'))
time.sleep(1)
user_list[i][1].send(content.encode('utf-8'))
fflag = 1
if user_list[i][0] == raw_user:
raw_conn = user_list[i][1]
if fflag == 1:
for i in range(len(user_list)):
if user_list[i][0] == raw_user:
raw_conn = user_list[i][1]
raw_conn.send(content.encode('utf-8'))
if fflag == 0:
for i in range(len(user_list)):
if user_list[i][0] == raw_user:
warning = '<user>系统消息</user>\n<time>系统消息</time>\n<msg>您 @ ' \
'的用户不存在或当前不在线!消息发送失败!</msg>\n<svtime>系统消息</svtime> '
raw_conn.send(content.encode('utf-8'))
time.sleep(1)
raw_conn.send(warning.encode('utf-8'))
else:
for i in range(len(user_list)):
user_list[i][1].send(content.encode('utf-8'))
if flag == 'users':
data = json.dumps(content)
for i in range(len(user_list)):
try:
user_list[i][1].send(data.encode())
except:
pass
使用的库和全局变量
import os
import re
import time
import json
import queue
import datetime
import os.path
import threading
from socket import *
from threading import Thread
PATH = '/Users/indecreasy/Desktop/212_MPD/P13/'
LOCALHOST = '127.0.0.1' # LOCALHOST IP 号,用于在本地模拟服务端和客户端
PORT = 4399
BUFFERS = 1024
MAXC = 64
user_list = []
messages = queue.Queue()
lock = threading.Lock()
聊天信息的保存
疑难解决
- 过程中遇到了 地址被占用的情况,情况如:
socket.error: [Errno 48] Address already in use
//如上报错,错误号48;端口已被占用。
- 参考:
sudo lsof -i:4399
sudo kill *pid*
- 将占用端口的python进程结束,解决了该问题。
运行截图
警报信息示例:
References
https://www.liujiangblog.com/course/python/76
https://www.cnblogs.com/Beyond-Ricky/p/8079602.html
https://blog.csdn.net/calling_wisdom/article/details/42524745
https://zhuanlan.zhihu.com/p/269100912
https://blog.csdn.net/weixin_46112690/article/details/121704851
https://zhuanlan.zhihu.com/p/140496484
https://blog.csdn.net/t8116189520/article/details/80137916
源代码
客户端
import os
import json
import datetime
from socket import *
from tkinter import *
import tkinter.messagebox
from threading import Thread
from tkinter.scrolledtext import ScrolledText
PATH = '/Users/indecreasy/Desktop/212_MPD/P13/'
LOCALHOST = '127.0.0.1' # LOCALHOST IP 号,用于在本地模拟服务端和客户端
PORT = 4399
users = []
BUFFERS = 1024
MAXC = 64
CHARTLOG = []
def save_log(path, content):
with open(path, 'a', encoding='utf-8') as f:
f.writelines(content)
f.close()
class Chatter:
def __init__(self):
self.welcome()
# user = 'Issac'
# IP = '127.0.0.1'
# PORT = 4399
self._user = user
self._ip = IP
self._port = int(PORT)
self._filepath = '{}/{}.txt'.format(PATH, self._user)
file = open(self._filepath, 'w')
file.close()
self._client = socket(AF_INET, SOCK_STREAM)
self.get_socket()
def welcome(self):
# 创建登陆界面
welcome = Tk()
welcome.title('🎉欢迎使用易聊聊天室 - Welcome to YChat!')
welcome.geometry('700x450')
leftFrame = Frame(welcome)
rightFrame = Frame(welcome)
leftFrame.grid(column=0, row=0)
rightFrame.grid(column=1, row=0)
photo = PhotoImage(file="bg.png")
theLabel = Label(leftFrame,
image=photo, # 加入图片
compound=CENTER, # 关键:设置为背景图片
fg="white") # 前景色
theLabel.pack(pady=10)
nameLabel = Label(rightFrame,
text='请输入你的昵称:',
font=('PingFang HK', 14),
height=1,
fg='black')
nameLabel.grid(row=0, sticky=W)
usernameEntry = Entry(rightFrame,
font=('PingFang HK', 20),
width=17, )
usernameEntry.grid(row=1, sticky=W, pady=10)
ipLabel = Label(rightFrame,
text='请输入聊天室 IP:',
font=('PingFang HK', 14),
height=1,
fg='black')
ipLabel.grid(row=2, sticky=W)
ipEntry = Entry(rightFrame,
font=('PingFang HK', 20),
width=17, )
ipEntry.grid(row=3, sticky=W, pady=10)
portLabel = Label(rightFrame,
text='请输入端口号:',
font=('PingFang HK', 14),
height=1,
fg='black')
portLabel.grid(row=4, sticky=W)
portEntry = Entry(rightFrame,
font=('PingFang HK', 20),
width=17, )
portEntry.grid(row=5, sticky=W, pady=10)
def login():
global user, IP, PORT
user = usernameEntry.get()
IP = ipEntry.get()
PORT = portEntry.get()
if not user:
tkinter.messagebox.showwarning('⚠️输入错误!', message='用户名为空!')
else:
if not IP:
tkinter.messagebox.showwarning('⚠️输入错误!', message='IP 地址为空!')
else:
if not PORT:
tkinter.messagebox.showwarning('⚠️输入错误!', message='端口号为空!')
else:
welcome.destroy()
button = Button(rightFrame,
text='进入聊天室',
font=('PingFang HK', 14),
height=2,
width=12,
command=login)
button.grid(row=6, pady=15)
button = Button(rightFrame,
text='退出',
font=('PingFang HK', 14),
height=2,
width=12,
command=exit)
button.grid(row=7)
welcome.mainloop()
def get_socket(self):
def receive():
global fg, bg
while True:
msg = self._client.recv(BUFFERS).decode('utf-8')
print(msg)
print('\n')
try:
uses = json.loads(msg)
listbox1.delete(0, tkinter.END)
listbox1.insert(tkinter.END, "当前在线用户")
listbox1.insert(tkinter.END, "------Group chat-------")
for x in range(len(uses)):
listbox1.insert(tkinter.END, uses[x])
users.append('------Group chat-------')
except:
user = re.findall(r'<user>(.+)</user>', msg)
message = re.findall(r'<msg>(.+)</msg>', msg)
time = re.findall(r'<svtime>(.+)</svtime>', msg)
save_log(self._filepath, msg)
user = user[0]
message = message[0]
time = time[0]
listbox.tag_config('tag2',
foreground='white',
backgroun='blue')
listbox.tag_config('tag3',
foreground='red',
backgroun='yellow')
listbox.tag_config('tag4',
foreground='black',
backgroun='white')
outputmsg = '{}\n{}: {}\n'.format(time, user, message)
if user == self._user:
listbox.insert(tkinter.END, outputmsg, 'tag2')
elif user == '系统消息':
listbox.insert(tkinter.END, outputmsg, 'tag3')
else:
listbox.insert(tkinter.END, outputmsg, 'tag4')
listbox.see(tkinter.END)
self._client.connect((self._ip, self._port))
user_info = '<user>{}</user>'.format(self._user)
self._client.send(user_info.encode('utf-8'))
def sendmsg():
msg = entryIuput.get()
now_time = datetime.datetime.now()
msg_cont = """\n<user>{}</user>\n<time>{}</time>\n<msg>{}</msg>\n""". \
format(self._user, str(now_time), msg)
self._client.send(msg_cont.encode('utf-8'))
INPUT.set('')
# 聊天室 GUI
root = Tk()
root.geometry('800x600')
root.title('💬聊天室 - YChat - 用户: {}'.format(self._user))
root.resizable(0, 0)
# 消息栏
listbox = ScrolledText(root,
font=('PingFang HK', 16))
listbox.place(x=20,
y=20,
width=580,
height=380)
listbox.tag_config('tag1',
foreground='red',
backgroun="yellow")
listbox.insert(tkinter.END, '欢迎进入群聊,大家开始聊天吧!\n', 'tag1')
# 输入框
INPUT = StringVar()
INPUT.set('')
entryIuput = Entry(root,
width=120,
textvariable=INPUT,
font=('PingFang HK', 16))
entryIuput.place(x=20,
y=420,
width=580,
height=160)
# 在线用户列表
listbox1 = Listbox(root,
font=('PingFang HK', 14))
listbox1.place(x=620,
y=20,
width=160,
height=380)
sendButton = tkinter.Button(root,
text="发 送",
anchor='n',
command=sendmsg,
font=('PingFang HK', 18), bg='white', pady=12)
sendButton.place(x=620,
y=470,
width=160,
height=60)
# 首先启动接收进程
q = Thread(target=receive)
q.start()
root.mainloop()
save_log(self._filepath, CHARTLOG)
self._client.close()
if __name__ == '__main__':
a = Chatter()
服务器
import os
import re
import time
import json
import queue
import datetime
import os.path
import threading
from socket import *
from threading import Thread
PATH = '/Users/indecreasy/Desktop/212_MPD/P13/'
LOCALHOST = '127.0.0.1' # LOCALHOST IP 号,用于在本地模拟服务端和客户端
PORT = 4399
BUFFERS = 1024
MAXC = 64
user_list = []
messages = queue.Queue()
lock = threading.Lock()
def save_log(path, content):
with open(path, 'a', encoding='utf-8') as f:
f.writelines(content)
f.close()
def onlines(): # 统计当前在线人员
online = []
for i in range(len(user_list)):
online.append(user_list[i][0])
return online
class Manager:
"""
服务器的 Manager 类,用于完成要求的需求
- __init__()
- speak() 完成接受于广播
- launch() 启动后的初始化内容
"""
def __init__(self, ip, port):
self._ip = ip
self._port = port
self._filepath = '{}/{}.txt'.format(PATH, 'server')
file = open(self._filepath, 'w')
file.close()
self.launch()
def launch(self):
server = socket(AF_INET, SOCK_STREAM)
server.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1) # 端口释放后马上可以被重新使用
server.bind((self._ip, self._port))
server.listen(MAXC)
print('Server is started...')
q = threading.Thread(target=self.senddata)
q.start()
while True:
# 接受连接后,启动一个线程处理该连接,主线程继续处理其他连接
conn, addr = server.accept()
t = Thread(target=self.speak, args=(conn, addr))
t.start()
server.close()
def speak(self, conn, addr):
user_info = conn.recv(BUFFERS).decode('utf-8') # 接收自动播报的用户名
# 提取用户名
# 正则表达式匹配, 提取用户名
nameMatch = re.match(r'^<user>', user_info)
if nameMatch:
pattern = re.compile('<user>(.+)</user>')
username = pattern.findall(user_info)
username = username[0]
user_list.append((username, conn, addr))
online_users = onlines()
self.load(online_users, addr, flag='users')
boarding = '{} ({} - {}) 进入了聊天室'.format(username, addr[0], addr[1])
print(boarding)
try:
while True:
msg = conn.recv(BUFFERS).decode('utf-8')
server_time = datetime.datetime.now()
msg = msg + '<svtime>{}</svtime>\n'.format(str(server_time))
self.load(msg, addr, flag='msg')
conn.close()
except:
# 用户断开连接
j = 0
for user in user_list:
if user[0] == username:
user_list.pop(j) # 服务器段删除退出的用户
break
j = j + 1
online_users = onlines()
self.load(online_users, addr, flag='users')
conn.close()
def load(self, content, addr, flag):
lock.acquire()
try:
messages.put((content, addr, flag))
finally:
lock.release()
def senddata(self):
# 将信息和用户列表转发给各个客户端
global raw_conn
while True:
if not messages.empty():
message = messages.get()
content, addr, flag = message
if flag == 'msg':
save_log(self._filepath, content)
# 判断原语句中是否有@
atMatch = re.search(r'<msg>(@.+) ', content)
if atMatch:
pattern = re.findall(r'@(.+) ', content)
user = pattern[0]
raw_user = re.findall(r'<user>(.+)</user>', content)
raw_user = raw_user[0]
fflag = 0
for i in range(len(user_list)):
if user_list[i][0] == user:
warning = '<user>系统消息</user>\n<time>系统消息</time>\n<msg' \
'>有人对您发送了私聊信息!</msg>\n<svtime>系统消息</svtime> '
user_list[i][1].send(warning.encode('utf-8'))
time.sleep(1)
user_list[i][1].send(content.encode('utf-8'))
fflag = 1
if user_list[i][0] == raw_user:
raw_conn = user_list[i][1]
if fflag == 1:
for i in range(len(user_list)):
if user_list[i][0] == raw_user:
raw_conn = user_list[i][1]
raw_conn.send(content.encode('utf-8'))
if fflag == 0:
for i in range(len(user_list)):
if user_list[i][0] == raw_user:
warning = '<user>系统消息</user>\n<time>系统消息</time>\n<msg>您 @ ' \
'的用户不存在或当前不在线!消息发送失败!</msg>\n<svtime>系统消息</svtime> '
raw_conn.send(content.encode('utf-8'))
time.sleep(1)
raw_conn.send(warning.encode('utf-8'))
else:
for i in range(len(user_list)):
user_list[i][1].send(content.encode('utf-8'))
if flag == 'users':
data = json.dumps(content)
for i in range(len(user_list)):
try:
user_list[i][1].send(data.encode())
except:
pass
if __name__ == '__main__':
manager = Manager(LOCALHOST, PORT)