提示:本文主要介绍了如何在Python中利用WebSocket技术实现多人聊天通信。
文章目录
项目成品展示
加入第三个人后(理论上可以加入N个人)
前言
第一次发CSDN😂,简单分享一下
去年自己在学习的时候学习到了WebSocket这个技术,当时觉得挺新鲜和好玩的,可以实现远程收发消息,所以后来我就利用Android开发了一个客户端并把它部署在了一个免费的服务器上(送的一个月😂),我当时迫不及待的找了几个人做测试,发现还真的可以用!但是后面服务器过期之后再加上我又把我的电脑系统给重装了,重装了系统之后之前写的代码就都不见了( ̄﹃ ̄),这次我学聪明了,我准备把他分享在网上😂(虽然也不是什么重要的东西😂),如果能帮助到一些人学到一点东西,也挺不错的。好了,不要再啰嗦了,正片开始!
一、主要用到哪些技术?
客户端(这里是用python编写的控制台应用程序)
- python(客户端逻辑开发)
- websocket(本项目中实现收发消息最主要的技术)
- pyinstaller(用它把我们开发好的程序打包成.exe文件)
- colorama(用它来改变控制台输出的颜色)
- pygame(用它来播放背景音乐)
- requests(用来请求网络)
- datetime(格式化日期)
- threading(多线程相关)
- json(解析json)
服务端
- spring boot(开发后端程序,用它来实现消息转发)
- websocket(同样也会用到它)
二、原理解析
原理其实很简单,主要是利用WebSocket技术将用户想要发送的消息发送到服务端,服务端解析我们发送的消息,然后将解析后的消息进行一个封装,封装好指定的格式后转发给其他当前连接到服务器的其他用户就可以了,在这个案例中,服务器主要的作用就是实现消息转发功能,而客户端主要的作用就是发送消息到服务端和接收服务端的消息并展示。
三、什么是WebSocket?
上述说到WebSocket是:本文中实现收发消息最主要的技术!所以我们要先来简单了解一下WebSocket这个技术
以下内容摘自某AI:
WebSocket是一种在客户端和服务器之间进行双向通信的通信协议。它允许服务器主动将数据推送给客户端,而不需要客户端发送请求。相比传统的HTTP请求,WebSocket具有更低的延迟和更高的效率,因为它使用了持久连接,避免了频繁的建立和关闭连接的开销。
WebSocket协议基于TCP协议,通过使用特定的握手机制进行连接的建立,之后双方可以在随后的连接中自由地发送和接收数据。WebSocket可以在Web浏览器中直接使用,也可以在其他客户端应用程序中使用。它已经被广泛应用于实时聊天、在线游戏、股票行情等领域。
四、在Python中使用WebSocket
WebSocket可以在很多平台上使用,比如:Web浏览器、移动设备、桌面应用程序等,这次我是利用Python开发的一款比较简陋的控制台程序,但是对于学习来说还相对简单一点,不用搞那些图形化界面的东西。
1.引入WebSocket库
代码如下(示例):
# websocket
import websocket
2.创建一个WebSocket对象
代码如下(示例):
# websocket的url的格式大概长这样
# ws://host:port/path
# 这里的path要跟后端的设置要一致
ws = websocket.WebSocketApp(url,
on_open=on_open,
on_message=on_message,
on_error=on_error,
on_close=on_close
)
除了url
这个参数,其他的参数实际上是在给这个ws
对象绑定回调函数,比如on_open
参数,这个参数的作用是:指定在将来打开连接时应该回调哪个函数,这里指定的是on_open
函数(名称一样而已)。
3.调用WebSocket对象的run_forever()
方法
代码如下(示例):
ws.run_forever()
run_forever()
是 WebSocket 的方法之一,用于在服务器端持续运行 WebSocket 连接。 它的作用是让 WebSocket 服务器一直运行,接收和处理来自 WebSocket 客户端的消息,并在必要时向客户端发送消息。
需要注意的是:run_forever()
这个方法会一直阻塞当前线程,直到 WebSocket 连接关闭或发生错误。这个方法通常在启动 WebSocket 服务器时使用,以便持续监听来自客户端的消息。
4.多线程
上面讲到了run_forever()
方法会一直阻塞当前线程,而在Python中实现用户输入的方法input()
也是一个阻塞的方法,它们两者如果运行在同一个线程中会造成“互斥”,即:同时只能有一个方法被执行(执行一个时会把当前线程阻塞住,另一个方法没办法执行),但是我们又必须使用input()
方法来实现在python程序运行过程中让用户进行输入(毕竟我们的程序主要的功能是聊天),所以这个时候我们就得使用一下多线程了,因为它们两个方法都是阻塞方法(都会阻塞当前线程),那理论上我们只要给它们两个阻塞方法都放到一个单独的线程中去运行,把它们两个“隔离”起来,不就解决了吗?
代码如下(示例):
# 创建线程对象
receive_thread = threading.Thread(target=ws_receive, args=(ws,))
send_thread = threading.Thread(target=input_and_send, args=(ws,))
# 开启两个线程一个是接收消息用的,一个是发送消息用的
receive_thread.start()
send_thread.start()
# 等待线程执行完毕
receive_thread.join()
send_thread.join()
target
参数是指定具体的线程执行的方法
args
参数是指定调用具体执行的方法时所需要的参数(注意一定要加后面的逗号)
这样做就可以做到接收消息和发送消息互不干扰了!关于这部分的代码就不展开说了,具体的可以查看源代码。
五、完整的代码(Python)
# 解析json
import json
# 操作系统
import os
import sys
# 多线程
import threading
import time
# 网络请求
import requests
# websocket
import websocket
# 控制台输出样式
from colorama import Fore, Back, init
# 日期
from datetime import datetime
# 用于播放背景音乐
import pygame
def on_message(ws, message):
debug_print(f"Received from server: {message}")
data = json.loads(message)
if data['code'] == 2:
# 系统消息
msg = f'系统:{data['msg']}'
show_system_message(False, msg)
elif data['code'] == 1:
now = datetime.now()
# 用户消息
msg = f'[{now.strftime('%H:%M:%S')}][{'🩵' if data['data']['gender'] == 0 else '🩷'}/{data['data']['nickname']}]\u0020==>\u0020{data['data']['message']}'
show_message(False, msg)
else:
# 操作失败
print(f'{Fore.RED}\r获取数据失败:{data['msg']}')
restore_input_state()
def on_error(ws, error):
show_system_message(True, f'WebSocket错误:{error}')
def on_close(ws, close_status_code, close_msg):
show_system_message(True, '服务器已断开')
global connection_state
connection_state = False
debug_print(f'close_status_code: {close_status_code}')
debug_print(f'close_msg: {close_msg}')
def on_open(ws):
# print("### connected ###")
show_system_message(False, '服务器已连接')
global connection_state
connection_state = True
print('现在你可以开始发送消息了')
def print_message(msg):
print(f'{Back.GREEN}{Fore.BLACK}{'\u0020' * 2}{app_name}(版本:{app_version}){'\u0020' * 2}')
print(f'{Back.LIGHTBLACK_EX}{'#' * title_bar_length}')
print(msg)
print(f'{Back.LIGHTBLACK_EX}{'#' * title_bar_length}')
print(f'{Back.YELLOW}{Fore.BLACK}{'-' * title_bar_length}')
def show_system_message(is_error, msg):
if not is_error:
if len(msg) > 40:
print(f'\r{Fore.GREEN}###\u0020🤖:{msg}\u0020###', flush=True)
else:
print(f'\r{Fore.GREEN}###\u0020🤖:{msg}\u0020###{'\u0020' * 40}', flush=True)
else:
if len(msg) > 40:
print(f'\r{Fore.RED}###\u0020🤖:{msg}\u0020###', flush=True)
else:
print(f'\r{Fore.RED}###\u0020🤖:{msg}\u0020###{'\u0020' * 40}', flush=True)
def show_message(is_send, msg):
if is_send:
if len(msg) > 40:
print(f'\r{Fore.GREEN}[发送]{Fore.CYAN}{msg}', flush=True)
else:
print(f'\r{Fore.GREEN}[发送]{Fore.CYAN}{msg}{'\u0020' * 40}', flush=True)
else:
if len(msg) > 40:
print(f'\r{Fore.LIGHTBLACK_EX}[接收]{Fore.YELLOW}{msg}', flush=True)
else:
print(f'\r{Fore.LIGHTBLACK_EX}[接收]{Fore.YELLOW}{msg}{'\u0020' * 40}', flush=True)
def debug_print(msg):
if debug:
print(msg)
def ws_receive(ws):
ws.run_forever()
def input_and_send(ws):
time.sleep(0.5)
global chat_state
chat_state = True
while True:
send_text = input('输入你想发送的消息(输入exit退出):').strip()
if 'exit' != send_text and connection_state:
if '' == send_text:
show_system_message(True, '该条消息内容为空,不允许发送!')
continue
ws.send(f'{user_nickname}-:-{user_gender}-:-{send_text}')
now = datetime.now()
show_message(True,
f'[{now.strftime('%H:%M:%S')}][{'🩵' if user_gender == 0 else '🩷'}/{user_nickname}]\u0020==>\u0020{send_text}')
else:
if not connection_state:
print(f'{Fore.RED}连接已经关闭,自动退出发送状态!')
break
chat_state = False
if connection_state:
ws.close()
def restore_input_state():
if connection_state and chat_state:
print('\r输入你想发送的消息(输入exit退出):', end='')
def resource_path(relative_path):
# 这种方式好像打包后获取不到解压的mp3
# try:
# base_path = sys._MEIP
# except Exception:
# base_path = os.path.abspath(".")
# return os.path.join(base_path, relative_path)
# 这种方式可以获取到
if getattr(sys, 'frozen', False): # 是否以封装的exe形式运行
# 如果是,获取解压后的文件目录
mp3_path = os.path.join(sys._MEIPASS, relative_path)
else:
# 如果不是,使用当前脚本目录
mp3_path = os.path.join(os.path.dirname(__file__), relative_path)
return mp3_path
# 服务端默认地址
server_address = 'localhost'
# 服务端默认端口
server_port = '8081'
# 用户昵称
user_nickname = ''
# 用户性别
user_gender = 0
# 运行指令
command = 'run'
# 运行提示信息
tips_str = '暂无操作提示'
# DEBUG模式
debug = False
# 聊天状态
chat_state = False
# 连接状态
connection_state = False
# 应用名称
app_name = '魔法聊天屋'
# 版本
app_version = '1.3'
# 联系方式
app_contact = 'QQ:104*****86'
# 提示TITLE条长度
title_bar_length = 60
# 背景音乐(0为关闭,1为打开)
bgm = 0
# 背景音乐当前的播放状态(-1未初始化,0已经初始化但是未播放,1已经初始化已经在播放)
bgm_state = -1
if __name__ == "__main__":
# websocket.enableTrace(True) # Optional: print debug info
# 初始化colorama
init(autoreset=True)
# 初始化pygame.mixer
# 获取音乐文件路径
music_path = resource_path("bgm.mp3")
pygame.mixer.init()
pygame.mixer.music.load(music_path)
bgm_state = 0
is_open_music = input('是否打开背景音乐(Y/N):').lower()
if 'y' == is_open_music or 'yes' == is_open_music:
pygame.mixer.music.play(loops=-1) # 无限循环播放背景音乐
bgm_state = 1
bgm = 1
# # 开始运行程序,清一下屏幕
os.system('cls')
while 'exit' != command:
if 'tips' == command:
print(f'{Fore.YELLOW}{tips_str}')
tips_str = '暂无操作提示'
command = 'run'
continue
else:
try:
hello = f'{Fore.YELLOW}欢迎来到{app_name}!\n==>\u0020run(运行程序)\n==>\u0020help(查看帮助)\n==>\u0020bgm({'打开音乐' if bgm == 0 else '关闭音乐'})\n==>\u0020exit(退出)\n==>\u0020cls(清理屏幕)'
print_message(hello)
user_input = input("请输入运行命令:").strip().lower()
if 'run' == user_input:
user_input = input('输入本次聊天所使用的昵称:').strip().replace('-:-', '')
user_nickname = user_input
user_input = eval(input('输入你的性别(0代表男,1代表女):').strip())
user_gender = 1 if user_input == 1 else 0
user_input = input('输入服务器地址:').strip().replace('http://', '').replace('https://', '')
if ':' in user_input:
parseList = user_input.split(':')
if len(parseList) == 2:
server_address = parseList[0]
server_port = parseList[1]
else:
tips_str = '输入的地址有误,请重新输入!'
command = 'tips'
continue
else:
server_address = user_input
user_input = eval(input('输入服务器端口:').strip())
server_port = user_input
url = f'ws://{server_address}'
if server_port != 80:
url = f'{url}:{server_port}'
# 获取用户唯一标识ID
print(f'{Fore.LIGHTBLACK_EX}正在获取用户唯一标识,请稍候……')
# 目标URL
api = f'{url.replace('ws://', 'http://')}/rid'
# 发送GET请求
response = requests.get(api, timeout=5)
# 打印响应内容
debug_print(response.text)
# 解析JSON字符串
data = json.loads(response.text)
if data['code'] == 1:
# 获取成功
print(f'{Fore.LIGHTBLACK_EX}用户唯一标识已成功获取,正在与服务器建立连接……')
rid = data['data']
url = f'{url}/chat/{rid}'
debug_print('最终url:' + url)
ws = websocket.WebSocketApp(url,
on_open=on_open,
on_message=on_message,
on_error=on_error,
on_close=on_close
)
# 创建线程对象
receive_thread = threading.Thread(target=ws_receive, args=(ws,))
send_thread = threading.Thread(target=input_and_send, args=(ws,))
# 开启两个线程一个是接收消息用的,一个是发送消息用的
receive_thread.start()
send_thread.start()
# 等待线程执行完毕
receive_thread.join()
send_thread.join()
else:
print(f'{Fore.RED}获取数据失败:{data['msg']}')
continue
elif 'help' == user_input:
print(
f'{Fore.GREEN}{app_name}是一个基于 WebSocket 技术构建的多人在线聊天系统!')
print()
print(
f'{Fore.GREEN}主要功能:')
print(
f'{Fore.GREEN}\u3000\u3000\u3000\u3000\u30001、所有连接到{app_name}的用户可以在同一个聊天空间内进行交流,当一个用户发送消息后,消息会被广播给所有其他在线用户,实现即时的群体互动。')
print(
f'{Fore.GREEN}\u3000\u3000\u3000\u3000\u30002、循环播放背景音乐😂。')
print()
print(
f'{Fore.GREEN}问题说明:')
print(
f'{Fore.GREEN}\u3000\u3000\u3000\u3000\u30001、输入服务器地址和端口时请确保输入的是纯文本(不带格式),输入的时候支持一次性输入使用IP:PORT的形式。')
print(
f'{Fore.GREEN}\u3000\u3000\u3000\u3000\u30002、当前总在线人数代表的是目前有多少人在线,如果服务器提示总在线人数为1人,那么此时就只有你一个人在线,无法和别人聊天。')
print(
f'{Fore.GREEN}\u3000\u3000\u3000\u3000\u30003、如果连接不上服务器则代表服务器可能发生异常或未启动,这个时候请联系开发者进行处理。')
print()
print(f'{Fore.GREEN}背景音乐:陈致逸-战斗 - 通往胜利 (纯音乐)')
print(f'{Fore.LIGHTBLUE_EX}本项目开发完成于2024年09月16日!')
print(
f'{Fore.GREEN}使用过程中如果遇到其他更多问题请联系开发者,联系方式:{app_contact}!')
input('按任意键继续……')
continue
elif 'bgm' == user_input:
if bgm_state == 0:
pygame.mixer.music.play(loops=-1) # 无限循环播放背景音乐
bgm_state = 1
bgm = 1
elif bgm_state == 1:
if bgm == 0:
pygame.mixer.music.unpause()
else:
pygame.mixer.music.pause()
bgm = 1 if bgm == 0 else 0
else:
print(f'{Fore.RED}播放器状态异常,请重新启动本程序!')
continue
elif 'exit' == user_input:
print(f'{Fore.GREEN}再见,期待下次与您相遇!')
time.sleep(1)
break
elif 'cls' == user_input:
os.system('cls')
continue
else:
print(f'{Fore.RED}您的输入有误,请重新输入!')
continue
except KeyboardInterrupt:
debug_print(f'{Fore.RED}键盘输入中断!程序退出!')
break
except Exception as e:
tips_str = f'{Fore.RED}程序运行出错:{e},请重试!'
command = 'tips'
六、后端的实现
这个项目的后端其实非常简单,就只有几个类,这里大致贴一下代码,需要深入研究的稍后会提供源文件下载链接,请自行下载查看。(不过阅读的话可能需要有点JAVA后端的基础)
IDController
生成一个随机的唯一的UUID用于不同用户的身份判别
UUID:意为"通用唯一识别码",是一个128位长的标识符,在计算机系统中用于唯一地标识信息。
package com.example.chatdemo.controller;
import com.example.chatdemo.result.Result;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.UUID;
@RestController
@RequestMapping("/rid")
public class IDController {
/**
* 生成随机ID
* @return 随机的UUID
*/
@GetMapping
public Result<String> generateID() {
UUID uuid = UUID.randomUUID();
return Result.success("user_".concat(uuid.toString()));
}
}
最主要是这个类,用来处理消息的
package com.example.chatdemo.websocket;
import com.example.chatdemo.result.Result;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.websocket.OnClose;
import jakarta.websocket.OnMessage;
import jakarta.websocket.OnOpen;
import jakarta.websocket.Session;
import jakarta.websocket.server.PathParam;
import jakarta.websocket.server.ServerEndpoint;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
@Component
@ServerEndpoint("/chat/{userId}")
public class WebSocketServer {
private static final ConcurrentMap<String, Session> clients = new ConcurrentHashMap<>();
private static final String APP_NAME = "魔法聊天屋";
/**
* 连接建立成功调用的方法
*
* @param session 当前连接会话
* @param userId 当前连接用户id
*/
@OnOpen
public void onOpen(Session session, @PathParam("userId") String userId) throws Exception {
clients.put(userId, session);
System.out.println("有新连接加入!用户id为:" + userId + ",当前在线人数为:" + clients.size());
//向当前连接用户发送当前在线人数
ObjectMapper objectMapper = new ObjectMapper();
String dateTimeStr = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss", Locale.CHINA));
String message = String.format("欢迎来到%s!当前(%s)总在线人数:%s人。", APP_NAME,
dateTimeStr,
clients.size());
String sendMessage = objectMapper.writeValueAsString(Result.system(message));
session.getAsyncRemote().sendText(sendMessage);
//通知其他人有新用户上线
message = String.format("有用户加入%s!" +
"当前(%s)总在线人数:%s人。", APP_NAME, dateTimeStr, clients.size());
String notifyAll = objectMapper.writeValueAsString(Result.system(message));
sendMessageToAllUser(userId, notifyAll);
}
/**
* 接收客户端消息
*
* @param userId 当前连接用户id
* @param message 客户端发送的消息
*/
@OnMessage
public void onMessage(@PathParam("userId") String userId, String message) throws Exception {
ObjectMapper objectMapper = new ObjectMapper();
Map<String, Object> map = new HashMap<>();
map.put("from", userId);
String[] parseMessage = message.split("-:-");
if (parseMessage.length == 3) {
map.put("nickname", parseMessage[0].isEmpty() ? "匿名用户(" + userId + ")" : parseMessage[0]);
map.put("gender", Integer.parseInt(parseMessage[1]));
map.put("message", parseMessage[2]);
} else {
map.put("nickname", "匿名用户(" + userId + ")");
map.put("gender", 0);
map.put("message", message);
}
String sendMessage = objectMapper.writeValueAsString(Result.success(map));
System.out.println("收到用户id为:" + userId + "的消息:" + sendMessage);
sendMessageToAllUser(userId, sendMessage);
}
/**
* 连接关闭调用的方法
*
* @param userId 当前连接用户id
*/
@OnClose
public void onClose(@PathParam("userId") String userId) throws Exception {
Session exitedUser = clients.remove(userId);
if (exitedUser != null) exitedUser.close();
System.out.println("有用户关闭连接!当前在线人数为:" + clients.size());
ObjectMapper objectMapper = new ObjectMapper();
String dateTimeStr = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss", Locale.CHINA));
String message = String.format("有用户退出%s!当前(%s)总在线人数:%s人。", APP_NAME, dateTimeStr, clients.size());
String notifyAll = objectMapper.writeValueAsString(Result.system(message));
sendMessageToAllUser(userId, notifyAll);
}
private void sendMessageToAllUser(String userId, String message) throws Exception {
clients.forEach((key, value) -> {
if (!key.equals(userId) && value != null) {
value.getAsyncRemote().sendText(message);
System.out.println("转发消息到:" + userId);
}
});
}
}
代码大部分还是很简单,有一些细节问题如果看不懂,建议去网上查询一下,也可以借助AI工具(不得不说AI工具真的太好用了,有什么不懂的问题都可以问他),如果还是不懂的话也欢迎在评论区留言,这样的话大家和我看到也都会回复的。
七、资源下载
百度网盘:
链接:魔法聊天屋
提取码:ltw2
资源文件说明
Python:该文件夹里面存放的是Python的源代码文件和背景音乐
后端:该文件夹里面是SpringBoot的项目源代码
ChatDemo-0.0.3-RELEASE.jar:该文件是打包好的后端项目jar包
魔法聊天屋1.3.exe:该文件是打包好的可执行文件(双击即可运行)
如何使用?
直接双击运行打包好的.exe
可执行文件和使用:java -jar ChatDemo-0.0.3-RELEASE.jar
运行打包好的.jar文件就可以看到效果了。
如果需要使用pyinstaller
把Python源文件和mp3文件一起打包成.exe
文件的话可以直接运行这条命令:pyinstaller --onefile --add-data ".\your_path\bgm.mp3;."
,执行完毕后会在项目的dist
目录生成相应的可执行文件,这时候再执行上面提到的操作就可以了。
如果需要实现远程聊天功能,直接把后端程序部署在云服务器上就可以了,然后在客户端(这里是用Python编写的控制台应用程序)运行的时候填写远程的IP和端口。如果你没有云服务器的话也可以使用另一种办法叫内网穿透的技术来实现,具体的可以上网查一查。
总结
本篇文章主要讲述了如何利用Python实现一个多人聊天案例,鉴于本人能力和知识的局限,文中内容可能存在不足之处,诚挚地欢迎各位专家、同仁不吝赐教,指出错误与疏漏之处,我将不胜感激。
结语
最后,祝愿大家在学业上勇攀高峰,在工作中乘风破浪,收获属于自己的精彩。