第十五章:Python 网络编程全解析 15.1 网络编程基础 网络编程 是 Python 强大的应用领域之一,它使开发者能够创建通过网络通信的应用程序,从简单的客户端-服务器模型到复杂的分布式系统。Python 提供了丰富的标准库和第三方库来简化网络编程任务。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 ''' 网络编程基本概念: - 协议(Protocol): 通信规则和格式的约定 - 套接字(Socket): 网络通信的端点 - 客户端-服务器模型: 最常见的网络通信模式 - 端口(Port): 区分主机上不同应用程序的数字标识符 - IP地址: 标识网络上设备的地址 Python网络编程的优势: - 简洁的API,降低开发复杂度 - 丰富的标准库和第三方库支持 - 跨平台兼容性好 - 适合快速原型开发和生产级应用 '''
在深入具体的协议和实现之前,理解以下基础概念非常重要:
概念 描述 重要性 OSI 七层模型 网络通信的概念框架,从物理层到应用层 理解不同协议的位置和作用 TCP/IP 模型 互联网的实际实现模型,包括链路层、网络层、传输层和应用层 掌握实际网络通信的基础 套接字编程 通过套接字 API 进行网络通信的编程模型 Python 网络编程的基础 阻塞与非阻塞 IO 不同的 IO 模型对网络编程效率的影响 影响程序架构和性能
🔍 深入理解 :网络编程中的不同层次使用不同的协议。传输层常见的是 TCP 和 UDP,而应用层则有 HTTP、FTP、SMTP 等。Python 提供了所有这些协议的编程接口。
15.2 Socket 编程详解 套接字(Socket)是网络编程的基础,它提供了进程间通信的端点。Python 的 socket
模块实现了 BSD 套接字标准。
15.2.1 TCP 套接字 TCP(传输控制协议)是一种面向连接的、可靠的、基于字节流的传输层通信协议。
TCP 套接字特性 特性 描述 优点/缺点 连接导向 使用前需要建立连接 确保通信双方都准备好传输数据 可靠传输 通过确认机制保证数据传输 保证数据完整性,但增加延迟 流控制 通过滑动窗口机制控制发送速率 防止接收方缓冲区溢出 拥塞控制 监测网络拥塞状态并调整发送速率 防止网络拥塞崩溃 字节流 数据作为连续字节流处理 需要自己定义消息边界
Python 中的 TCP 客户端示例 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 import socketdef tcp_client (): """简单的TCP客户端示例""" client = socket.socket(socket.AF_INET, socket.SOCK_STREAM) try : client.connect(('example.com' , 80 )) print ("已连接到服务器" ) request = "GET / HTTP/1.1\r\nHost: example.com\r\n\r\n" client.sendall(request.encode()) response = b'' while True : chunk = client.recv(4096 ) if not chunk: break response += chunk print (f"收到 {len (response)} 字节的响应:" ) print (response.decode()[:200 ] + "..." ) finally : client.close() print ("连接已关闭" ) if __name__ == "__main__" : tcp_client()
Python 中的 TCP 服务器示例 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 import socketimport threadingdef handle_client (client_socket, address ): """处理客户端连接 Args: client_socket: 客户端套接字 address: 客户端地址 """ print (f"接受来自 {address} 的连接" ) data = client_socket.recv(1024 ) response = f"收到 {len (data)} 字节的数据" .encode() client_socket.send(response) client_socket.close() print (f"与 {address} 的连接已关闭" ) def tcp_server (): """简单的多线程TCP服务器""" server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1 ) server.bind(('0.0.0.0' , 8888 )) server.listen(5 ) print ("服务器启动,监听端口 8888..." ) try : while True : client, address = server.accept() client_thread = threading.Thread( target=handle_client, args=(client, address) ) client_thread.daemon = True client_thread.start() except KeyboardInterrupt: print ("服务器关闭中..." ) finally : server.close() if __name__ == "__main__" : tcp_server()
15.2.2 UDP 套接字 UDP(用户数据报协议)是一种无连接的传输层协议,它不保证数据传输的可靠性,但具有较低的延迟。
UDP 套接字特性 特性 描述 优点/缺点 无连接 无需建立连接即可发送数据 降低延迟,但无法确保对方准备好 不可靠传输 不保证数据到达或顺序 丢包风险,但降低延迟 数据报方式 数据作为独立的包进行处理 保留消息边界,但有大小限制 无流控制 发送方可以任意速率发送数据 可能导致网络拥塞和接收方缓冲区溢出 低开销 没有连接管理和可靠性保证的开销 适合实时应用如视频流和游戏
Python 中的 UDP 客户端示例 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 import socketdef udp_client (): """简单的UDP客户端示例""" client = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) server_address = ('localhost' , 9999 ) try : message = "Hello, UDP Server!" client.sendto(message.encode(), server_address) print (f"已发送消息: {message} " ) client.settimeout(5 ) try : data, server = client.recvfrom(4096 ) print (f"收到来自 {server} 的响应: {data.decode()} " ) except socket.timeout: print ("接收响应超时" ) finally : client.close() print ("套接字已关闭" ) if __name__ == "__main__" : udp_client()
Python 中的 UDP 服务器示例 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 import socketdef udp_server (): """简单的UDP服务器示例""" server = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) server_address = ('0.0.0.0' , 9999 ) server.bind(server_address) print (f"UDP服务器启动,监听 {server_address} ..." ) try : while True : data, client_address = server.recvfrom(4096 ) print (f"收到来自 {client_address} 的 {len (data)} 字节数据" ) message = data.decode() print (f"收到消息: {message} " ) response = f"服务器已收到: {message} " server.sendto(response.encode(), client_address) except KeyboardInterrupt: print ("服务器关闭中..." ) finally : server.close() if __name__ == "__main__" : udp_server()
15.2.3 TCP 与 UDP 对比 特性 TCP UDP 最适用场景 连接 需要建立连接 无连接 TCP 适合需要可靠通信的应用 可靠性 高(确认、重传、排序) 低(无保证) TCP 适合文件传输、网页浏览 性能 较低(连接开销、拥塞控制) 高(低延迟) UDP 适合实时多媒体、游戏 数据流 字节流(无边界) 数据报(有边界) TCP 适合流式传输,UDP 适合消息传输 头部大小 20 字节+选项 8 字节 UDP 适合小数据包场景 流控/拥塞控制 有 无 TCP 适合防止网络拥塞的场景
15.3 通用网络协议与 Python 实现 本章节中,我们会学习常见的协议(其中较为常见的协议会比较简略带过,仅提供示例 demo)以及推荐常用的第三方库等,对于一些不常见,而在我们开发中需要进阶使用的协议,我们会进行详解
15.3.1 HTTP/HTTPS 协议 HTTP(超文本传输协议)和 HTTPS(安全的超文本传输协议)是万维网的基础。
HTTP/HTTPS 协议特性 特性 描述 HTTP HTTPS 传输安全 数据加密 无加密 TLS/SSL 加密 默认端口 服务器监听端口 80 443 证书验证 服务器身份验证 无 数字证书 请求/响应模式 通信模式 客户端请求,服务器响应 客户端请求,服务器响应 无状态 不保存会话信息 是 是 数据格式 传输格式 文本/二进制 加密的文本/二进制
Python 标准库 HTTP 客户端 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 import http.clientimport jsondef http_client_standard (): """使用标准库的HTTP客户端""" conn = http.client.HTTPSConnection("api.github.com" ) headers = { 'User-Agent' : 'Python HTTP Client' , 'Accept' : 'application/json' } try : conn.request("GET" , "/users/octocat" , headers=headers) response = conn.getresponse() print (f"状态码: {response.status} {response.reason} " ) data = response.read().decode('utf-8' ) user_data = json.loads(data) print (f"用户名: {user_data.get('login' )} " ) print (f"个人主页: {user_data.get('html_url' )} " ) finally : conn.close() if __name__ == "__main__" : http_client_standard()
推荐的第三方 HTTP 库 库名 特性 适用场景 安装命令 requests(重点)
简化的 API,丰富的功能,可读性强 一般 HTTP 客户端需求 pip install requests
aiohttp(重点)
异步 HTTP 客户端/服务器 高性能异步应用 pip install aiohttp
httpx
支持同步和异步,HTTP/1.1 和 HTTP/2 现代 HTTP 客户端需求 pip install httpx
urllib3
低级 HTTP 客户端,连接池,重试 需要细粒度控制的场景 pip install urllib3
pycurl
libcurl 的 Python 绑定,高性能 复杂的 HTTP 客户端需求 pip install pycurl
requests 库示例 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 import requestsdef requests_example (): """使用requests库的示例""" url = "https://api.github.com/users/octocat" headers = { 'User-Agent' : 'Python Requests' , 'Accept' : 'application/json' } try : response = requests.get(url, headers=headers) response.raise_for_status() user_data = response.json() print (f"用户名: {user_data.get('login' )} " ) print (f"个人主页: {user_data.get('html_url' )} " ) print (f"公开仓库数: {user_data.get('public_repos' )} " ) print ("\n响应头信息:" ) for key, value in response.headers.items(): print (f"{key} : {value} " ) except requests.exceptions.HTTPError as e: print (f"HTTP错误: {e} " ) except requests.exceptions.ConnectionError as e: print (f"连接错误: {e} " ) except requests.exceptions.Timeout as e: print (f"请求超时: {e} " ) except requests.exceptions.RequestException as e: print (f"请求异常: {e} " ) if __name__ == "__main__" : requests_example()
aiohttp 异步 HTTP 示例 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 import aiohttpimport asyncioasync def fetch_user (username ): """异步获取用户信息 Args: username: GitHub用户名 """ url = f"https://api.github.com/users/{username} " headers = { 'User-Agent' : 'Python aiohttp' , 'Accept' : 'application/json' } async with aiohttp.ClientSession() as session: async with session.get(url, headers=headers) as response: if response.status == 200 : data = await response.json() return { 'username' : data.get('login' ), 'name' : data.get('name' ), 'repos' : data.get('public_repos' ) } else : print (f"获取用户 {username} 失败: {response.status} " ) return None async def main (): """主函数""" usernames = ["octocat" , "torvalds" , "gvanrossum" ] tasks = [fetch_user(username) for username in usernames] results = await asyncio.gather(*tasks) for user in results: if user: print (f"用户: {user['username' ]} , 姓名: {user['name' ]} , 仓库数: {user['repos' ]} " ) if __name__ == "__main__" : asyncio.run(main())
15.3.2 FTP/SFTP 协议 FTP(文件传输协议)和 SFTP(安全文件传输协议)用于在网络中传输文件。
FTP/SFTP 协议特性 特性 FTP SFTP 注意事项 安全性 不加密(明文传输) SSH 加密通道 FTP 不适合传输敏感数据 认证 用户名/密码(明文) SSH 认证机制 SFTP 支持密钥认证 复杂性 需要控制连接和数据连接 单一连接 FTP 需要处理主动/被动模式 防火墙兼容性 较差(需要多个端口) 良好(单一端口) FTP 可能需要特殊配置 标准化 RFC 959 无正式 RFC,基于 SSH SFTP 是 SSH 协议的扩展 默认端口 21(控制)+ 数据端口 22(SSH 端口) FTP 数据端口不固定
Python 标准库 FTP 客户端 以下是 Python 中 FTP 客户端常用 API 的简洁总结,使用表格展示:
方法 描述 使用场景 ftplib.FTP()
创建一个 FTP 对象 用于连接到 FTP 服务器 ftp.login()
登录到 FTP 服务器 进行身份验证 ftp.cwd()
改变当前目录 切换服务器上的工作目录 ftp.retrbinary()
以二进制模式下载文件 下载文件(例如图片、视频等) ftp.storbinary()
以二进制模式上传文件 上传文件(例如图片、视频等) ftp.retrlines()
以文本模式获取文件内容 获取文件内容并输出到控制台 ftp.storlines()
以文本模式上传文件 上传文本文件(例如配置文件) ftp.mkd()
创建目录 在 FTP 服务器上创建新目录 ftp.rmd()
删除目录 删除 FTP 服务器上的目录 ftp.delete()
删除文件 删除 FTP 服务器上的文件 ftp.quit()
退出 FTP 连接 断开与 FTP 服务器的连接
快速搭建一个本地的 FTP 服务器 【1】配置 IIS Web 服务器
【1.1】控制面板找到“程序”并打开
【1.2】程序界面找到“启用或关闭 Windows 功能”并打开
【1.3】从“启用或关闭 Windows 功能”弹窗中找到 Internet Information Services(或者中文版 Internet 信息服务)并打开
【1.4】配置 IIS 并点击确定
【2】第二步:配置 IIS web 站点
【2.1】 开始菜单搜索“IIS”并点击进入 IIS 管理器
【2.2】新建 FTP 站点、新增 FTP 服务器根目录文件夹
【2.3】查看本机 ip 地址,后续访问 Ftp 地址需要用到(打开 cmd 输入 ipconfig)
【2.4】IIS 网站管理器界面左边导航栏找到“网站”,右键弹出菜单
【2.5】配置网站
【3】测试 FTP 站点(先在物理路径:E:\ftpserver 随便放一个文件)
在浏览器访问 ftp:ip 就能看到预期的文件
我们很有可能会遇到三个问题:
实现 FTP 文件上传下载 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 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 from ftplib import FTPimport osfrom contextlib import contextmanager@contextmanager def ftp_connection (host, port=21 , user=None , passwd=None , encoding="GB18030" , timeout=30 ): """创建 FTP 连接的上下文管理器,确保连接正确关闭 Args: host: FTP 服务器地址 port: FTP 服务器端口,默认为 21 user: FTP 用户名,默认为 None(匿名登录) passwd: FTP 密码,默认为 None encoding: 服务器编码,默认为 GB18030 timeout: 连接超时时间(秒),默认为 30 Yields: ftplib.FTP: 已连接的 FTP 对象 """ ftp = FTP() ftp.encoding = encoding try : ftp.connect(host, port, timeout) ftp.login(user, passwd) yield ftp finally : if ftp: ftp.quit() def upload_file (local_path, remote_path, filename, host="172.17.115.3" , port=21 , user=None , passwd=None ): """上传本地文件到 FTP 服务器 Args: local_path: 本地文件的完整路径 remote_path: FTP 服务器上的目标路径 filename: 上传后的文件名 host: FTP 服务器地址,默认为 172.17.115.3 port: FTP 服务器端口,默认为 21 user: FTP 用户名,默认为 None(匿名登录) passwd: FTP 密码,默认为 None Returns: bool: 上传成功返回 True,否则返回 False """ try : with ftp_connection(host, port, user, passwd) as ftp: ftp.cwd(remote_path) with open (local_path, "rb" ) as local_file: ftp.storbinary("STOR " + filename, local_file) print ("文件上传成功!" ) return True except Exception as e: print ("文件上传失败!" , e) return False def download_file (remote_path, remote_filename, local_path, host="172.17.115.3" , port=21 , user=None , passwd=None ): """从 FTP 服务器下载文件到本地 Args: remote_path: FTP 服务器上的文件路径 remote_filename: 要下载的文件名 local_path: 本地保存路径 host: FTP 服务器地址,默认为 172.17.115.3 port: FTP 服务器端口,默认为 21 user: FTP 用户名,默认为 None(匿名登录) passwd: FTP 密码,默认为 None Returns: bool: 下载成功返回 True,否则返回 False """ try : with ftp_connection(host, port, user, passwd) as ftp: ftp.cwd(remote_path) with open (local_path, "wb" ) as local_file: ftp.retrbinary("RETR " + remote_filename, local_file.write) print ("文件下载成功!" ) return True except Exception as e: print ("文件下载失败!" , e) return False def list_files (remote_path, host="172.17.115.3" , port=21 , user=None , passwd=None ): """列出 FTP 服务器上指定目录中的文件 Args: remote_path: FTP 服务器上的目录路径 host: FTP 服务器地址,默认为 172.17.115.3 port: FTP 服务器端口,默认为 21 user: FTP 用户名,默认为 None(匿名登录) passwd: FTP 密码,默认为 None Returns: list: 文件名列表 """ files = [] try : with ftp_connection(host, port, user, passwd) as ftp: ftp.cwd(remote_path) ftp.retrlines("LIST" , lambda x: files.append(x.split()[-1 ])) return files except Exception as e: print ("文件列表获取失败!" , e) return [] if __name__ == '__main__' : upload_file( local_path=r"test3.txt" , remote_path="/upload" , filename="test3.txt" , ) download_file( remote_path="/upload" , remote_filename="test3.txt" , local_path=r"test3_downloaded.txt" , ) files = list_files( remote_path="/upload" , ) print ("FTP服务器上的文件列表:" , files)
推荐的 FTP/SFTP 第三方库 库名 特性 适用场景 安装命令 paramiko
SFTP 客户端/服务器,SSH 实现 需要安全文件传输 pip install paramiko
pysftp
基于 paramiko 的高级 SFTP 客户端 简化的 SFTP 操作 pip install pysftp
ftputil
FTP 客户端,类似文件系统 API 复杂的 FTP 操作 pip install ftputil
aioftp
异步 FTP 客户端/服务器 高性能异步文件传输 pip install aioftp
scp
基于 SSH 的简单文件复制 简单文件传输需求 pip install scp
15.3.3 WebSocket 协议 WebSocket 协议提供了在单个 TCP 连接上进行全双工通信的能力,特别适用于需要实时通信的应用。
WebSocket 协议特性 特性 描述 优势 全双工通信 客户端和服务器可以同时发送和接收数据 减少延迟,提高实时性 持久连接 建立一次连接后长期保持 减少连接建立的开销 低延迟 无需为每个消息建立新连接 适合实时应用如聊天、游戏 基于 TCP 在可靠传输之上构建 保证消息可靠送达 支持二进制和文本 可传输任何类型的数据 适用范围广泛 标准化 RFC 6455 定义 广泛支持,客户端兼容性好
推荐的 WebSocket 第三方库 库名 特性 适用场景 安装命令 websockets
纯 Python 异步 WebSocket 实现 异步 WebSocket 应用 pip install websockets
GoEazy
免费简单的 API 连接调用 全面的 Websocket 应用 网络搜索 GoEazy
websockets 库示例 方法 描述 使用场景 websocket.WebSocketApp()
创建 WebSocket 应用实例 用于初始化 WebSocket 客户端 ws.run_forever()
启动 WebSocket 客户端并保持连接 用于运行 WebSocket 连接,保持客户端在线 ws.send()
发送消息到 WebSocket 服务器 向服务器发送数据 ws.recv()
接收来自 WebSocket 服务器的消息 从服务器接收消息 ws.close()
关闭 WebSocket 连接 关闭 WebSocket 连接 ws.on_message()
处理接收到的消息事件 用于定义接收到消息时的回调函数 ws.on_error()
处理错误事件 用于定义错误发生时的回调函数 ws.on_close()
处理连接关闭事件 用于定义 WebSocket 连接关闭时的回调函数 ws.on_open()
处理连接打开事件 用于定义 WebSocket 连接建立时的回调函数
使用 websockets 实现在线聊天室 前端实现(VUE3 + tailwindcss + daisyUi) – 可以不用理解,直接复制到 HTML 文件即可
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 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 <!DOCTYPE html > <html lang ="en" > <head > <meta charset ="UTF-8" > <meta name ="viewport" content ="width=device-width, initial-scale=1.0" > <title > WebSocket聊天应用</title > <script src ="https://cdn.tailwindcss.com" > </script > <link href ="https://cdn.jsdelivr.net/npm/daisyui@3.9.4/dist/full.css" rel ="stylesheet" type ="text/css" /> <script src ="https://unpkg.com/vue@3/dist/vue.global.js" > </script > <style > @keyframes fadeIn { from { opacity : 0 ; transform : translateY (10px ); } to { opacity : 1 ; transform : translateY (0 ); } } .message-animation { animation : fadeIn 0.3s ease-out forwards; } .system-message { text-align : center; padding : 0.5rem ; margin : 0.5rem 0 ; background-color : #f3f4f6 ; border-radius : 0.5rem ; color : #6b7280 ; font-size : 0.875rem ; } </style > </head > <body > <div id ="app" class ="container mx-auto px-4 py-8 max-w-4xl" > <header class ="flex justify-between items-center mb-4 bg-gray-100 p-4 rounded-lg" > <div class ="flex items-center gap-4" > <h1 class ="text-2xl font-bold text-center" > WebSocket聊天应用</h1 > <div class ="dropdown dropdown-hover" > <label tabindex ="0" class ="btn btn-ghost btn-circle avatar" > <div class ="w-10 rounded-full" > <img :src ="userAvatar" alt ="用户头像" /> </div > </label > <div tabindex ="0" class ="dropdown-content z-[1] card card-compact w-64 p-2 shadow bg-base-100" > <div class ="card-body" > <h3 class ="font-bold text-lg" > 个人信息</h3 > <div class ="form-control" > <label class ="label" > <span class ="label-text" > 用户名</span > </label > <input type ="text" v-model ="currentUser" placeholder ="输入用户名" class ="input input-bordered w-full" @change ="updateUserInfo" /> </div > <div class ="form-control mt-2" > <label class ="label" > <span class ="label-text" > 选择头像</span > </label > <div class ="grid grid-cols-3 gap-2" > <div v-for ="(avatar, index) in avatarOptions" :key ="index" @click ="selectAvatar(avatar)" class ="w-12 h-12 rounded-full cursor-pointer hover:ring-2 hover:ring-primary" :class ="{'ring-2 ring-primary': userAvatar === avatar}" > <img :src ="avatar" class ="w-full h-full rounded-full object-cover" /> </div > </div > </div > </div > </div > </div > </div > <div class ="flex flex-col items-end gap-1" > <div class ="badge badge-outline" :class ="isConnected ? 'badge-success text-white' : 'badge-error text-white'" > {{ connectionStatus }} </div > <div class ="text-xs text-gray-500" v-if ="isConnected" > 在线用户: {{ onlineUsersCount }} </div > </div > </header > <div class ="flex mb-4" > <div class ="w-1/4 mr-4 card bg-base-100 shadow-md" > <div class ="card-body p-4" > <h2 class ="card-title text-lg mb-2" > 在线用户</h2 > <div class ="divider my-1" > </div > <div class ="overflow-y-auto max-h-80" > <div v-for ="user in onlineUsers" :key ="user.client_id" class ="flex items-center gap-2 mb-2" > <div class ="avatar" > <div class ="w-8 h-8 rounded-full" > <img :src ="user.avatar" alt ="User avatar" /> </div > </div > <span class ="font-medium" :class ="{'text-primary': user.client_id === clientId}" > {{ user.username }} <span v-if ="user.client_id === clientId" > (我)</span > </span > </div > </div > </div > </div > <div class ="w-3/4 card bg-base-100 shadow-md" > <div class ="card-body h-96 overflow-y-auto" ref ="messageContainer" > <div v-if ="messages.length === 0" class ="h-full flex items-center justify-center text-gray-400" > <div class ="flex flex-col gap-4 items-center" > <svg xmlns ="http://www.w3.org/2000/svg" fill ="none" viewBox ="0 0 24 24" stroke-width ="1.5" stroke ="currentColor" class ="w-12 h-12" > <path stroke-linecap ="round" stroke-linejoin ="round" d ="M12 20.25c4.97 0 9-3.694 9-8.25s-4.03-8.25-9-8.25S3 7.444 3 12c0 2.104.859 4.023 2.273 5.48.432.447.74 1.04.586 1.641a4.483 4.483 0 01-.923 1.785A5.969 5.969 0 006 21c1.282 0 2.47-.402 3.445-1.087.81.22 1.668.337 2.555.337z" /> </svg > <p > 暂无消息,开始聊天吧!</p > </div > </div > <div v-else > <div v-for ="(message, index) in messages" :key ="index" class ="message-animation mb-2" > <div v-if ="message.type === 'system'" class ="system-message" > {{ message.content }} </div > <div v-else :class ="isSelfMessage(message) ? 'chat chat-end' : 'chat chat-start'" > <div class ="chat-header" > <div class ="chat-title" > {{ message.sender }}</div > </div > <div class ="chat-image avatar" > <div class ="w-10 rounded-full" > <img alt ="User avatar" :src ="message.avatar" /> </div > </div > <div class ="chat-bubble" :class ="{'chat-bubble-primary': isSelfMessage(message)}" > {{ message.content }} </div > <div class ="chat-footer opacity-50 text-xs" > {{ message.timestamp }}</div > </div > </div > </div > </div > </div > </div > <div class ="card bg-base-100 shadow-md rounded-lg" > <div class ="card-body" > <div class ="form-control" > <div class ="flex flex-row justify-between items-center gap-2" > <input type ="text" placeholder ="请输入你要发送的内容" v-model ="newMessage" class ="input input-bordered flex-grow" @keyup.enter ="sendMessage" :disabled ="!isConnected" /> <button class ="btn btn-primary" @click ="sendMessage" :disabled ="!isConnected || !newMessage.trim()" > <svg xmlns ="http://www.w3.org/2000/svg" fill ="none" viewBox ="0 0 24 24" stroke-width ="1.5" stroke ="currentColor" class ="w-6 h-6" > <path stroke-linecap ="round" stroke-linejoin ="round" d ="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5" /> </svg > 发送 </button > </div > </div > <button v-if ="!isConnected" class ="btn btn-primary mt-2" @click ="connectWebSocket" > <svg xmlns ="http://www.w3.org/2000/svg" fill ="none" viewBox ="0 0 24 24" stroke-width ="1.5" stroke ="currentColor" class ="w-6 h-6" > <path stroke-linecap ="round" stroke-linejoin ="round" d ="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" /> </svg > 连接服务器 </button > <button v-else class ="btn btn-error mt-2" @click ="disconnectWebSocket" > <svg xmlns ="http://www.w3.org/2000/svg" fill ="none" viewBox ="0 0 24 24" stroke-width ="1.5" stroke ="currentColor" class ="w-6 h-6" > <path stroke-linecap ="round" stroke-linejoin ="round" d ="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" /> </svg > 断开连接 </button > </div > </div > <div v-if ="connectionError" class ="alert alert-error mt-4" > {{ connectionError }} </div > </div > </body > <script > const { createApp, ref, computed, nextTick, onMounted, watch } = Vue ; createApp ({ setup ( ) { const isConnected = ref (false ); const messages = ref ([]); const newMessage = ref ('' ); const currentUser = ref ('我' ); const messageContainer = ref (null ); const connectionError = ref ('' ); const socket = ref (null ); const userAvatar = ref ('./images/1.png' ); const avatarOptions = ref ([ './images/1.png' , './images/2.png' , './images/3.png' , './images/4.png' , './images/5.png' , './images/6.png' ]); const clientId = ref ('' ); const onlineUsers = ref ([]); let heartbeatInterval = null ; const connectionStatus = computed (() => isConnected.value ? '已连接' : '未连接' ); const onlineUsersCount = computed (() => onlineUsers.value .length ); watch (messages, () => { nextTick (() => { if (messageContainer.value ) { messageContainer.value .scrollTop = messageContainer.value .scrollHeight ; } }); }, { deep : true }); const isSelfMessage = (message ) => { return message.client_id === clientId.value ; }; const updateUserInfo = ( ) => { if (!isConnected.value ) return ; try { socket.value .send (JSON .stringify ({ type : "user_info" , username : currentUser.value , avatar : userAvatar.value })); } catch (error) { console .error ('发送用户信息错误:' , error); } }; const selectAvatar = (avatar ) => { userAvatar.value = avatar; updateUserInfo (); }; const connectWebSocket = ( ) => { try { socket.value = new WebSocket ('ws://localhost:8765' ); socket.value .onopen = () => { isConnected.value = true ; connectionError.value = '' ; startHeartbeat (); }; socket.value .onmessage = handleMessage; socket.value .onclose = handleClose; socket.value .onerror = () => { connectionError.value = '连接出错,请检查服务器是否运行' ; }; } catch (error) { connectionError.value = '连接失败: ' + error.message ; isConnected.value = false ; } }; const startHeartbeat = ( ) => { heartbeatInterval = setInterval (() => { if (socket.value ?.readyState === WebSocket .OPEN ) { socket.value .send (JSON .stringify ({ type : 'ping' })); } }, 30000 ); }; const stopHeartbeat = ( ) => { if (heartbeatInterval) { clearInterval (heartbeatInterval); heartbeatInterval = null ; } }; const disconnectWebSocket = ( ) => { if (socket.value ?.readyState === WebSocket .OPEN ) { socket.value .close (1000 , '用户主动断开连接' ); } }; const handleMessage = (event ) => { try { const data = JSON .parse (event.data ); switch (data.type ) { case 'welcome' : clientId.value = data.client_id ; messages.value .push ({ type : 'system' , content : data.content }); break ; case 'users_list' : onlineUsers.value = data.users ; break ; case 'user_update' : const userIndex = onlineUsers.value .findIndex (u => u.client_id === data.client_id ); if (userIndex !== -1 ) { onlineUsers.value [userIndex].username = data.username ; onlineUsers.value [userIndex].avatar = data.avatar ; } break ; case 'chat' : messages.value .push ({ type : 'chat' , client_id : data.client_id , sender : data.sender , content : data.content , timestamp : data.timestamp , avatar : data.avatar }); break ; case 'system' : messages.value .push ({ type : 'system' , content : data.content }); break ; case 'error' : connectionError.value = data.message ; break ; } } catch (error) { console .error ('消息处理错误:' , error); } }; const handleClose = (event ) => { isConnected.value = false ; stopHeartbeat (); if (!event.wasClean ) { connectionError.value = '连接意外关闭' ; } else { connectionError.value = '连接已关闭' ; } clientId.value = '' ; socket.value = null ; onlineUsers.value = []; }; const sendMessage = ( ) => { if (!isConnected.value || !newMessage.value .trim ()) return ; try { const message = { type : "chat" , content : newMessage.value , timestamp : new Date ().toLocaleString () }; socket.value .send (JSON .stringify (message)); newMessage.value = '' ; } catch (error) { console .error ('消息发送错误:' , error); } }; onMounted (() => { }); return { isConnected, connectionStatus, messages, newMessage, messageContainer, currentUser, userAvatar, avatarOptions, connectionError, clientId, onlineUsers, onlineUsersCount, isSelfMessage, selectAvatar, updateUserInfo, connectWebSocket, disconnectWebSocket, sendMessage, }; }, }).mount ('#app' ); </script > </html >
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 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 import asyncioimport jsonimport loggingimport uuidfrom datetime import datetimefrom websockets.asyncio.server import ServerConnection, servefrom typing import Set , Dict , Any , Optional logging.basicConfig( level=logging.INFO, format ='%(asctime)s - %(name)s - %(levelname)s - %(message)s' ) class WebSocketServer : """WebSocket服务器类,处理客户端连接和消息通信""" def __init__ (self, host: str = "localhost" , port: int = 8765 ): """初始化WebSocket服务器 Args: host: 服务器主机名或IP地址 port: 服务器端口号 """ self .host = host self .port = port self .clients: Dict [str , ServerConnection] = {} self .client_info: Dict [str , Dict [str , Any ]] = {} self .logger = logging.getLogger('websocket_server' ) self .message_handlers = { 'chat' : self ._handle_chat_message, 'ping' : self ._handle_ping_message, 'user_info' : self ._handle_user_info } async def handle_message (self, client_id: str , message: str ) -> None : """处理从客户端接收到的消息 Args: client_id: 客户端ID message: 收到的消息内容 """ try : data = json.loads(message) await self ._process_message(client_id, data) except json.JSONDecodeError: await self ._send_error(client_id, "消息格式不是有效的JSON" ) except Exception as e: self .logger.error(f"处理消息时出错: {str (e)} " ) await self ._send_error(client_id, f"服务器处理消息时出错: {str (e)} " ) async def _process_message (self, client_id: str , data: Dict [str , Any ] ) -> None : """处理解析后的消息数据""" if 'type' not in data: await self ._send_error(client_id, "消息格式错误,缺少type字段" ) return message_type = data['type' ] handler = self .message_handlers.get(message_type) if handler: await handler(client_id, data) else : await self ._send_error(client_id, f"不支持的消息类型: {message_type} " ) async def _handle_user_info (self, client_id: str , data: Dict [str , Any ] ) -> None : """处理用户信息更新""" if 'username' not in data or 'avatar' not in data: await self ._send_error(client_id, "用户信息不完整,需要username和avatar字段" ) return self .client_info[client_id]['username' ] = data['username' ] self .client_info[client_id]['avatar' ] = data['avatar' ] user_info_message = json.dumps({ "type" : "user_update" , "client_id" : client_id, "username" : data['username' ], "avatar" : data['avatar' ] }) await self ._broadcast_message(user_info_message) self .logger.info(f"用户信息更新: {data['username' ]} " ) async def _handle_chat_message (self, client_id: str , data: Dict [str , Any ] ) -> None : """处理聊天消息""" if 'content' not in data: await self ._send_error(client_id, "消息缺少content字段" ) return username = self .client_info.get(client_id, {}).get('username' , '匿名用户' ) avatar = self .client_info.get(client_id, {}).get('avatar' , './images/1.png' ) timestamp = data.get('timestamp' , datetime.now().strftime("%Y-%m-%d %H:%M:%S" )) broadcast_message = json.dumps({ "type" : "chat" , "client_id" : client_id, "sender" : username, "content" : data['content' ], "timestamp" : timestamp, "avatar" : avatar }) await self ._broadcast_message(broadcast_message) self .logger.info(f"广播消息: {data['content' ]} (来自: {username} )" ) async def _handle_ping_message (self, client_id: str , data: Dict [str , Any ] ) -> None : """处理ping消息""" if client_id in self .clients: await self .clients[client_id].send(json.dumps({"type" : "pong" })) async def _send_error (self, client_id: str , message: str ) -> None : """发送错误消息到客户端""" if client_id in self .clients: await self .clients[client_id].send(json.dumps({ "type" : "error" , "message" : message })) async def _send_to_client (self, client_id: str , message: str ) -> None : """发送消息到特定客户端""" if client_id in self .clients: await self .clients[client_id].send(message) async def _broadcast_message (self, message: str , exclude_client: Optional [str ] = None ) -> None : """广播消息给所有客户端 Args: message: 要广播的消息 exclude_client: 要排除的客户端ID(可选) """ tasks = [] for cid, conn in self .clients.items(): if exclude_client is None or cid != exclude_client: tasks.append(conn.send(message)) if tasks: await asyncio.gather(*tasks) async def connection_handler (self, websocket: ServerConnection ) -> None : """处理WebSocket连接的主函数 Args: websocket: 客户端WebSocket连接 """ client_id = str (uuid.uuid4()) self .clients[client_id] = websocket self .client_info[client_id] = { "username" : f"用户{len (self.clients)} " , "avatar" : "./images/1.png" } client_info = f"{websocket.remote_address[0 ]} :{websocket.remote_address[1 ]} " self .logger.info(f"客户端连接: {client_info} (ID: {client_id} )" ) try : await self ._handle_new_connection(client_id) await self ._process_client_messages(client_id, websocket) except Exception as e: self .logger.error(f"连接处理出错: {str (e)} " ) finally : await self ._handle_disconnection(client_id) async def _handle_new_connection (self, client_id: str ) -> None : """处理新客户端连接""" welcome_message = json.dumps({ "type" : "welcome" , "client_id" : client_id, "content" : "已连接到WebSocket服务器" , "user_count" : len (self .clients) }) await self ._send_to_client(client_id, welcome_message) users_list = [] for cid, info in self .client_info.items(): users_list.append({ "client_id" : cid, "username" : info.get("username" , "匿名用户" ), "avatar" : info.get("avatar" , "./images/1.png" ) }) users_message = json.dumps({ "type" : "users_list" , "users" : users_list }) await self ._send_to_client(client_id, users_message) if len (self .clients) > 1 : username = self .client_info[client_id].get("username" , "匿名用户" ) connect_message = json.dumps({ "type" : "system" , "content" : f"用户 {username} 加入聊天,当前共有 {len (self.clients)} 名用户" }) await self ._broadcast_message(connect_message, exclude_client=client_id) async def _process_client_messages (self, client_id: str , websocket: ServerConnection ) -> None : """处理客户端消息流""" async for message in websocket: await self .handle_message(client_id, message) async def _handle_disconnection (self, client_id: str ) -> None : """处理客户端断开连接""" username = self .client_info.get(client_id, {}).get("username" , "匿名用户" ) if client_id in self .clients: del self .clients[client_id] if client_id in self .client_info: del self .client_info[client_id] if self .clients: disconnect_message = json.dumps({ "type" : "system" , "content" : f"用户 {username} 离开聊天,当前共有 {len (self.clients)} 名用户" }) await self ._broadcast_message(disconnect_message) async def start (self ) -> None : """启动WebSocket服务器""" self .logger.info(f"启动WebSocket服务器 - ws://{self.host} :{self.port} " ) async with serve(self .connection_handler, self .host, self .port): await asyncio.Future() def run (self ) -> None : """运行WebSocket服务器(阻塞调用)""" try : asyncio.run(self .start()) except KeyboardInterrupt: self .logger.info("服务器已通过键盘中断信号停止" ) except Exception as e: self .logger.error(f"服务器出错: {str (e)} " ) if __name__ == "__main__" : server = WebSocketServer(host="localhost" , port=8765 ) server.run()
15.3.4 MQTT 协议 MQTT (Message Queuing Telemetry Transport) 是一种基于 发布/订阅 (Publish/Subscribe) 模式的轻量级消息传输协议。它专门为 低带宽、高延迟或不可靠的网络 环境下的 资源受限设备 (如传感器、嵌入式设备) 设计。最初由 IBM 开发,现已成为 OASIS 国际标准。
为什么选择 MQTT?
轻量高效: 协议开销极小 (最小报文仅 2 字节),显著降低网络带宽消耗,适合物联网 (IoT) 场景中电池供电或蜂窝网络连接的设备。低功耗: 协议设计有助于减少客户端设备的能耗。可靠传输: 提供三种 服务质量 (QoS) 等级,确保在不稳定网络下的消息传输可靠性。发布/订阅模式: 发送者 (Publisher) 和接收者 (Subscriber) 通过中心 代理 (Broker) 解耦,无需知道对方的存在,甚至可以不同时在线 (取决于 QoS 和会话设置)。易于扩展: 设计上支持大量客户端并发连接。与常见协议对比:
HTTP: 对于请求/响应模式更简单,但头部开销大,每次通信通常需要建立新连接 (尤其对于频繁的小数据包),且服务器主动推送数据给客户端(长轮询、SSE)相对复杂或效率不高。 选择 MQTT 的场景: 频繁、小数据量的传输;低功耗/低带宽环境;需要服务端主动推送消息。CoAP (受限应用协议): 同样面向资源受限设备,基于 UDP。常被视为 MQTT 在 IoT 领域的竞争者或补充,更偏向 RESTful 风格。WebSockets: 提供基于单个 TCP 连接的全双工通信。适合需要实时交互的 Web 应用,但协议本身可能比 MQTT 更重。MQTT 应用场景 1.物联网 (IoT) - 核心领域 传感器数据采集: 环境监测: 智能楼宇、智慧城市中的温度、湿度、空气质量 (PM2.5)、光照强度等传感器数据上传。农业物联网: 土壤湿度、温度、作物生长环境监测。可穿戴设备: 智能手环、手表收集用户的步数、心率、睡眠数据并上传到云端。远程控制与状态同步: 智能家居: 控制智能灯泡开关/亮度/颜色、智能插座、空调、窗帘等;获取门锁状态、家电运行状态。保留消息
在此场景中非常有用,用于获取设备当前状态。共享单车/充电宝: 实时上报位置信息、电量状态、开关锁状态。2.工业物联网 (IIoT) 与 SCADA 设备远程监控与预警: 工厂生产线设备 (如 PLC、机器人) 运行状态、能耗数据、故障代码的实时上传与监控。 石油、天然气管道压力、流量、温度等参数监测。 电力系统 (智能电表) 数据采集与远程控制。 预测性维护: 收集设备振动、温度等数据,利用大数据分析预测潜在故障,提前进行维护。SCADA 系统集成: MQTT 作为一种轻量级协议,越来越多地被用于连接传统的 SCADA 系统和现代的云平台或移动应用。3.车联网 (IoV) 与远程信息处理 (Telematics) 车辆状态监控: 实时上传车辆 GPS 位置、速度、油耗、发动机转速、电池电压、故障码 (OBD 数据) 等。远程控制: 远程锁车/解锁、启动空调、鸣笛闪灯等。驾驶行为分析: 收集急加速、急刹车、急转弯等数据用于保险 UBI (Usage-Based Insurance) 或车队管理。信息推送: 推送路况信息、导航更新、软件更新通知 (OTA 更新通常结合 HTTP/HTTPS 处理大文件下载,MQTT 用于触发和状态同步)。为什么这些场景适合 MQTT?
大量连接: IoT、车联网通常涉及海量设备连接。低带宽/不稳定网络: 很多设备通过蜂窝网络 (2G/3G/4G/NB-IoT) 或 Wi-Fi 连接,网络条件可能不佳。资源受限: 许多设备是电池供电的嵌入式系统。实时性要求: 需要及时获取设备状态或数据。数据驱动: 主要是设备向云端上报数据,或云端向设备下发指令。MQTT 协议特性 特性 描述 优势 QoS 级别 提供三种服务质量级别(0,1,2) 可根据需要平衡可靠性与资源消耗 保留消息 服务器保存最后一条消息给新订阅者 确保新连接设备能获得最新状态 持久会话 客户端断开后可恢复订阅 提高系统稳定性和容错性 遗嘱消息 客户端异常断开时发布的特定消息 便于检测设备异常离线 小型客户端 代码占用空间小 适合嵌入式设备和 IoT 应用
推荐的 MQTT 第三方库 库名 特性 适用场景 安装命令 paho-mqtt
完整 MQTT 客户端实现 通用 MQTT 应用 pip install paho-mqtt
gmqtt
异步 MQTT 客户端 基于 asyncio 的应用 pip install gmqtt
hbmqtt
纯 Python 实现的 MQTT 客户端/服务器 需要轻量级 MQTT 服务器 pip install hbmqtt
amqtt
异步 MQTT 服务器和客户端 需要高性能 MQTT 服务器 pip install amqtt
paho-mqtt 示例 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 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 import paho.mqtt.client as mqttimport timeimport jsonimport sslimport uuid import logging logging.basicConfig(level=logging.INFO, format ='%(asctime)s - %(levelname)s - %(message)s' ) class MQTTClient : """ 使用 paho-mqtt 的 MQTT 客户端封装示例 (带注释和改进建议) 官方文档: [https://eclipse.dev/paho/files/paho.mqtt.python/html/index.html](https://eclipse.dev/paho/files/paho.mqtt.python/html/index.html) """ def __init__ (self, broker="broker.emqx.io" , port=1883 , client_id=None , use_tls=False , username=None , password=None , ca_certs=None , certfile=None , keyfile=None , clean_session=True , userdata=None ): """ 初始化MQTT客户端 Args: broker (str): MQTT Broker 地址. port (int): MQTT Broker 端口 (常见: 1883 for TCP, 8883 for TLS, 8083/8084 for WebSocket). client_id (str, optional): 客户端 ID. 如果为 None 或空字符串,Broker 会自动生成 (但不利于持久会话). 对于持久会话 (clean_session=False),必须提供一个固定且唯一的 ID. 建议使用设备序列号或其他唯一标识符. 默认自动生成一个. use_tls (bool, optional): 是否启用 TLS 加密连接. 默认为 False. username (str, optional): 连接 Broker 所需的用户名. 默认为 None. password (str, optional): 连接 Broker 所需的密码. 默认为 None. ca_certs (str, optional): CA 证书文件路径 (用于验证 Broker 证书). 默认为 None. certfile (str, optional): 客户端证书文件路径 (用于双向认证). 默认为 None. keyfile (str, optional): 客户端私钥文件路径 (用于双向认证). 默认为 None. clean_session (bool, optional): 控制会话类型 (MQTT v3.1.1). True: 清洁会话。断开连接时,Broker 清除所有会话信息 (订阅、离线消息)。 False: 持久会话。断开连接时,Broker 保留订阅和 QoS 1/2 离线消息。需要固定 client_id. 默认为 True. userdata: 传递给回调函数的用户自定义数据. """ if not client_id: self .client_id = f"python-mqtt-{uuid.uuid4()} " logging.warning(f"未提供 client_id,自动生成: {self.client_id} 。注意:这不利于使用持久会话。" ) clean_session = True else : self .client_id = client_id self .broker = broker self .port = port self .connected = False self ._subscribed_topics = set () self .client = mqtt.Client(client_id=self .client_id, clean_session=clean_session, userdata=userdata, protocol=mqtt.MQTTv311) if username is not None : if password is None : logging.warning("提供了用户名但未提供密码" ) self .client.username_pw_set(username, password) if use_tls: if port == 1883 : logging.warning(f"启用 TLS 但端口仍为 {port} (通常非 TLS 端口),请确认 Broker 配置。常用 TLS 端口为 8883。" ) try : self .client.tls_set(ca_certs=ca_certs, certfile=certfile, keyfile=keyfile, cert_reqs=ssl.CERT_REQUIRED if ca_certs else ssl.CERT_NONE, tls_version=ssl.PROTOCOL_TLSv1_2) except FileNotFoundError as e: logging.error(f"TLS 证书文件未找到: {e} " ) raise except ssl.SSLError as e: logging.error(f"TLS 配置错误: {e} " ) raise self .client.on_connect = self ._on_connect self .client.on_disconnect = self ._on_disconnect self .client.on_message = self ._on_message self .client.on_publish = self ._on_publish self .client.on_subscribe = self ._on_subscribe self .client.on_unsubscribe = self ._on_unsubscribe self .client.on_log = self ._on_log def _on_log (self, client, userdata, level, buf ): """日志回调,方便调试 paho 内部信息""" if level >= mqtt.MQTT_LOG_WARNING: logging.log(logging.INFO if level == mqtt.MQTT_LOG_INFO else logging.WARNING if level == mqtt.MQTT_LOG_WARNING else logging.ERROR if level == mqtt.MQTT_LOG_ERR else logging.DEBUG, f"[PAHO-LOG] {buf} " ) def _on_connect (self, client, userdata, flags, rc ): """连接回调""" connect_rc_codes = { 0 : "连接成功" , 1 : "连接被拒绝 - 不可接受的协议版本" , 2 : "连接被拒绝 - 标识符被拒绝 (例如 client_id 无效或已被使用)" , 3 : "连接被拒绝 - 服务器不可用" , 4 : "连接被拒绝 - 错误的用户名或密码" , 5 : "连接被拒绝 - 未授权" , } if rc == 0 : self .connected = True logging.info(f"成功连接到 Broker {self.broker} :{self.port} " ) if self ._subscribed_topics: logging.info("重新订阅之前的主题..." ) for topic, qos in self ._subscribed_topics: self .subscribe(topic, qos) else : self .connected = False logging.error(f"连接 Broker 失败, 返回码: {rc} - {connect_rc_codes.get(rc, '未知错误' )} " ) def _on_disconnect (self, client, userdata, rc ): """断开连接回调""" self .connected = False if rc == 0 : logging.info("已主动断开与 Broker 的连接" ) else : logging.warning(f"与 Broker 的连接意外断开, 返回码: {rc} . 可能需要重连。" ) def _on_message (self, client, userdata, msg ): """消息接收回调""" try : topic = msg.topic payload_str = msg.payload.decode('utf-8' ) qos = msg.qos retain = msg.retain logging.info(f"收到消息: 主题='{topic} ', QoS={qos} , Retain={retain} " ) logging.debug(f"原始载荷 (bytes): {msg.payload} " ) logging.info(f"消息内容 (str): {payload_str} " ) try : data = json.loads(payload_str) logging.info(f"解析后 JSON 内容: {data} " ) except json.JSONDecodeError: logging.debug("消息载荷不是有效的 JSON 格式" ) except Exception as e: logging.error(f"处理消息时发生错误: {e} " , exc_info=True ) except UnicodeDecodeError: logging.warning(f"收到无法以 UTF-8 解码的消息: 主题='{topic} ', QoS={qos} , Retain={retain} " ) logging.info(f"消息载荷 (bytes): {msg.payload} " ) except Exception as e: logging.error(f"处理 `on_message` 回调时发生严重错误: {e} " , exc_info=True ) def _on_publish (self, client, userdata, mid ): """ 消息发布回调 (QoS 1 和 2 会触发) mid (Message ID): 与 publish() 方法返回的 mid 对应。 """ logging.debug(f"消息 (MID: {mid} ) 已发布成功" ) def _on_subscribe (self, client, userdata, mid, granted_qos ): """ 订阅回调 mid: 消息 ID, 与 subscribe() 返回的 mid 对应. granted_qos: Broker 实际授予的 QoS 等级列表 (可能低于请求的 QoS). 例如,请求 (("topic1", 1), ("topic2", 2)), 返回可能是 (1, 1) """ logging.info(f"订阅成功 (MID: {mid} ), Broker 授予的 QoS: {granted_qos} " ) def _on_unsubscribe (self, client, userdata, mid ): """取消订阅回调""" logging.info(f"取消订阅成功 (MID: {mid} )" ) def connect (self, keepalive=60 , bind_address="" ): """ 连接到 MQTT Broker。 Args: keepalive (int): 心跳间隔秒数。客户端会以此间隔发送 PINGREQ, Broker 若在 1.5 * keepalive 内未收到任何消息,会认为客户端断开。 默认 60 秒。设置为 0 表示禁用心跳 (不推荐)。 bind_address (str): 绑定本地网络接口地址。默认为空。 """ if self .connected: logging.warning("客户端已连接,无需重复连接" ) return will_topic = f"clients/{self.client_id} /status" will_payload = json.dumps({"status" : "offline_unexpected" , "timestamp" : time.time()}) try : self .client.will_set(will_topic, payload=will_payload, qos=1 , retain=True ) logging.info(f"已设置遗嘱消息: 主题='{will_topic} ', 载荷='{will_payload} '" ) except Exception as e: logging.error(f"设置遗嘱消息失败: {e} " ) logging.info(f"尝试连接到 Broker {self.broker} :{self.port} ..." ) try : self .client.connect(self .broker, self .port, keepalive=keepalive, bind_address=bind_address) self .client.loop_start() logging.info("后台网络循环已启动 (loop_start)" ) except mqtt.WebsocketConnectionError as e: logging.error(f"WebSocket 连接错误: {e} . 请检查 Broker 是否启用了 WebSocket 以及端口是否正确 (通常是 8083/8084)。" ) raise except ConnectionRefusedError as e: logging.error(f"连接被拒绝: {e} . 请检查 Broker 地址、端口是否正确,以及 Broker 服务是否运行。" ) raise except OSError as e: logging.error(f"网络错误 (例如无法解析主机名或网络不可达): {e} " ) raise except Exception as e: logging.error(f"连接过程中发生未知错误: {e} " , exc_info=True ) if self .client.is_connected(): self .client.loop_stop() raise def disconnect (self ): """主动、正常地断开与 Broker 的连接""" if not self .connected: logging.warning("客户端未连接,无需断开" ) return logging.info("准备主动断开连接..." ) try : status_topic = f"clients/{self.client_id} /status" status_payload = json.dumps({"status" : "offline_clean" , "timestamp" : time.time()}) self .publish(status_topic, status_payload, qos=1 , retain=True ) logging.info(f"已发布正常离线状态: 主题='{status_topic} '" ) time.sleep(0.5 ) except Exception as e: logging.error(f"发布离线状态失败: {e} " ) self .client.loop_stop() logging.info("后台网络循环已停止 (loop_stop)" ) self .client.disconnect() def publish (self, topic, message, qos=0 , retain=False ): """ 发布消息到指定主题。 Args: topic (str): 目标主题. message (str | bytes | dict | int | float | bool): 消息内容. 如果是 dict,会自动转换为 JSON 字符串. 其他类型会尝试转换为字符串再编码为 bytes (UTF-8). 可以直接传入 bytes. qos (int): 服务质量等级 (0, 1, or 2). 默认为 0. retain (bool): 是否将此消息设置为保留消息. 默认为 False. Returns: mqtt.MQTTMessageInfo: 包含消息 ID (mid) 和返回码 (rc) 的对象. rc 为 MQTT_ERR_SUCCESS 表示成功加入发送队列. rc 为 MQTT_ERR_NO_CONN 表示未连接. rc 为 MQTT_ERR_QUEUE_SIZE 表示发送队列已满 (QoS 1/2). Raises: ValueError: 如果 QoS 无效. TypeError: 如果 message 类型无法处理. """ if not self .connected: logging.error("发布失败:客户端未连接到 Broker" ) return mqtt.MQTTMessageInfo(0 ) payload = None if isinstance (message, dict ): try : payload = json.dumps(message).encode('utf-8' ) except Exception as e: logging.error(f"字典转换为 JSON 失败: {e} " , exc_info=True ) raise TypeError("Failed to serialize dictionary to JSON" ) from e elif isinstance (message, str ): payload = message.encode('utf-8' ) elif isinstance (message, bytes ): payload = message elif isinstance (message, (int , float , bool )): payload = str (message).encode('utf-8' ) else : raise TypeError(f"不支持的消息类型: {type (message)} . 请提供 str, bytes, dict, int, float 或 bool." ) try : logging.debug(f"准备发布消息: 主题='{topic} ', QoS={qos} , Retain={retain} , 载荷(bytes)={payload[:100 ]} ..." ) result = self .client.publish(topic, payload=payload, qos=qos, retain=retain) if result.rc == mqtt.MQTT_ERR_SUCCESS: logging.debug(f"消息 (MID: {result.mid} ) 已成功加入发送队列 (主题: '{topic} ')" ) elif result.rc == mqtt.MQTT_ERR_NO_CONN: logging.error(f"发布失败 (MID: {result.mid} ): 未连接到 Broker" ) elif result.rc == mqtt.MQTT_ERR_QUEUE_SIZE: logging.warning(f"发布警告 (MID: {result.mid} ): 发送队列已满,消息可能延迟或丢失。请考虑降低发送频率或检查网络。" ) else : logging.error(f"发布失败 (MID: {result.mid} ): 未知错误代码 {result.rc} " ) return result except ValueError as e: logging.error(f"发布失败: 无效参数 - {e} " ) raise except Exception as e: logging.error(f"发布过程中发生未知错误: {e} " , exc_info=True ) raise def subscribe (self, topic, qos=0 ): """ 订阅一个主题。 Args: topic (str): 要订阅的主题 (可以包含通配符 '+' 或 '#'). '+': 匹配单层通配符, e.g., `sensor/+/temperature`. '#': 匹配多层通配符 (必须在末尾), e.g., `sensor/livingroom/#`. qos (int): 请求的 QoS 等级 (0, 1, or 2). Broker 可能授予较低的 QoS. Returns: tuple[int, int]: (result, mid) result: MQTT_ERR_SUCCESS (0) 表示成功,其他表示失败. mid: 消息 ID. Raises: ValueError: 如果 QoS 无效. ConnectionError: 如果客户端未连接. """ if not self .connected: logging.error("订阅失败:客户端未连接到 Broker" ) return (mqtt.MQTT_ERR_NO_CONN, 0 ) try : logging.info(f"尝试订阅主题: '{topic} ' (QoS: {qos} )" ) result, mid = self .client.subscribe(topic, qos) if result == mqtt.MQTT_ERR_SUCCESS: logging.debug(f"订阅请求已发送 (MID: {mid} )" ) self ._subscribed_topics.add((topic, qos)) else : logging.error(f"订阅请求失败,错误代码: {result} " ) return result, mid except ValueError as e: logging.error(f"订阅失败: 无效参数 - {e} " ) raise except Exception as e: logging.error(f"订阅过程中发生未知错误: {e} " , exc_info=True ) raise def unsubscribe (self, topic ): """ 取消订阅一个主题。 Args: topic (str): 要取消订阅的主题. Returns: tuple[int, int]: (result, mid) result: MQTT_ERR_SUCCESS (0) 表示成功,其他表示失败. mid: 消息 ID. Raises: ConnectionError: 如果客户端未连接. """ if not self .connected: logging.error("取消订阅失败:客户端未连接到 Broker" ) return (mqtt.MQTT_ERR_NO_CONN, 0 ) logging.info(f"尝试取消订阅主题: '{topic} '" ) try : result, mid = self .client.unsubscribe(topic) if result == mqtt.MQTT_ERR_SUCCESS: logging.debug(f"取消订阅请求已发送 (MID: {mid} )" ) self ._subscribed_topics = {(t, q) for t, q in self ._subscribed_topics if t != topic} else : logging.error(f"取消订阅请求失败,错误代码: {result} " ) return result, mid except Exception as e: logging.error(f"取消订阅过程中发生未知错误: {e} " , exc_info=True ) raise def mqtt_example_usage (): """MQTT 客户端使用示例""" BROKER_ADDRESS = "broker.emqx.io" BROKER_PORT = 1883 CLIENT_ID = f"python-mqtt-example-{uuid.uuid4()} " USE_TLS = False client = MQTTClient( broker=BROKER_ADDRESS, port=BROKER_PORT, client_id=CLIENT_ID, use_tls=USE_TLS, clean_session=True ) try : client.connect(keepalive=60 ) for _ in range (5 ): if client.connected: break time.sleep(1 ) if not client.connected: logging.error("连接超时,无法继续执行示例。" ) return sub_result, sub_mid = client.subscribe("test/topic/#" , qos=1 ) if sub_result != mqtt.MQTT_ERR_SUCCESS: logging.error("订阅失败!" ) message_data = { "device_id" : CLIENT_ID, "sensor_type" : "environment" , "location" : "living_room" , "temperature" : 23.5 + (time.time() % 5 - 2.5 ), "humidity" : 55 + (time.time() % 10 - 5 ), "timestamp" : time.time() } pub_info = client.publish( topic="test/topic/data" , message=message_data, qos=1 , retain=False ) if pub_info.rc == mqtt.MQTT_ERR_SUCCESS: logging.info(f"消息已发布 (MID: {pub_info.mid} )" ) else : logging.error("消息发布失败!" ) client.publish("test/topic/log" , "Client script started." , qos=0 ) logging.info("示例运行中,等待接收消息 (按 Ctrl+C 退出)..." ) try : while True : time.sleep(1 ) except KeyboardInterrupt: logging.info("收到退出信号 (Ctrl+C)" ) except ConnectionError as e: logging.error(f"MQTT 连接错误: {e} " ) except Exception as e: logging.error(f"发生未知错误: {e} " , exc_info=True ) finally : logging.info("示例结束,断开连接..." ) if 'client' in locals () and client: client.disconnect() if __name__ == "__main__" : mqtt_example_usage()
15.3.5 WebRTC 协议 WebRTC (Web Real-Time Communication) 是一种开放标准和 API,允许网页浏览器和移动应用程序之间无需复杂的服务器中介或插件即可进行实时通信。它主要支持点对点的语音通话、视频通话和任意数据传输,作为一项开放的实时通信标准,为开发者提供了快速构建实时音视频通话系统的能力
1.WebRTC 协议特性 特性 描述 优势 点对点通信 在客户端之间直接传输数据 减少服务器负载,降低延迟 实时音视频 支持高质量的音频和视频传输 适合视频会议、直播场景 数据通道 支持任意二进制数据传输 适合游戏、协作编辑等应用 NAT 穿透 通过 STUN/TURN/ICE 机制穿透防火墙 可在复杂网络环境下工作 端到端加密 使用 DTLS/SRTP 加密通信内容 保障通信安全性 带宽自适应 根据网络条件调整传输质量 提供流畅的用户体验
2.P2P 通信原理
传统通信方式需要通过客户端 A => 服务器 <== 客户端 B 进行通信,而 P2P 通信则可以通过 信令
来交换数据
要实现两个客户端的实时音视频通话,并且两个客户端可能处于不同网络环境,使用不同的设备,都需要解决以下三个问题:
如何发现对方? 不同的音视频解码能力如何沟通? 如何联系对方进行通信? 【1】如何发现对方?
在 P2P 通信的过程中,双方需要交换一些元数据比如媒体信息、网络数据等等信息,我们通常称这一过程叫做 信令
。对应的服务器即信令服务器, 通常也有人将之称为“房间服务器 ",因为它不仅可以交换彼此的媒体信息和网络信息,同样也可以管理房间信息。
比如:
1.通知彼此 [谁] 加入了房间 2.[谁] 离开了房间 3.告诉第三方房间是否已满或是否可以加入房间 为避免出现冗余,并最大限度的提高与已有技术的兼容性,WebRTC 标准并没有规定信令方法和协议,所以我们尽可能的使用学习过的 websocket 协议来搭建一个信令服务器
【2】不同的音视频解码能力如何沟通?
不同浏览器对于音视频的编码能力是不同的
比如:以生活的例子来讲,小李会讲汉语和英语,而小王会讲法语和汉语,为了保证双方都可以正确理解对方的意思,最简单的就是取他们都会的语言,也就是汉语来沟通
在 WebRTC 中有一个专门的协议,称作 Session Description Protocol(SDP)
,可以用于描述上述这类信息。
因此:参与音视频通讯的双方想要了解对方支持的媒体格式,必须交换 SDP 信息,而交换 SDP 的过程,通常称为媒体协商
【3】如何联系上对方
其实就是网络协商的过程,也就是参与音视频实时通信的双方要了解彼此的网络情况,这样才有可能找到一条相互通讯的链路
理想的网络情况是每个客户端都有自己的私网公网 IP 地址,这样就可以直接进行点对点连接。实际上呢,处于网络安全和其他原因考虑,大多数客户端之间是在某个局域网内,需要网络地址转换(NAT)。
在 WebRTC 中我们使用 ICE
机制建立网络连接。ICE
协议通过一系列技术如(STUN/TURN
服务器)帮助通信双方发现和协商可用的公共网络地址,实现 NAT 穿越
ICE
的工作原理如下:
1.首先,通信双方收集本地网络地址(包括私有和公共地址)以及通过 STUN
和 TURN
服务器获取的候选地址 2.接下来,双方通过信令服务器交换这些候选地址 3.通信双方使用这些候选地址进行连接测试,确定最佳的可用地址 4.一旦找到可用的地址,通信双方就可以开始实时音视频通话
在 WebRTC 中网络信息通常用 candidate
来描述
针对上面三个问题的总结:就是通过 WebRTC 提供的 API 获取各端的媒体信息 SDP
以及网络信息 candidate
并通过信令服务器交换,进而建立两端连接通道完成实时音视频通话
主要会用到如下的几个方法:
媒体协商方法:
createOffer
: 由发起方调用,创建一个包含其媒体能力和网络信息的 SDP Offer,用于启动连接协商。createAnswer
: 由接收方在收到 Offer 后调用,创建一个响应对方 Offer 的 SDP Answer。setLocalDescription
: 将本地生成的 Offer 或 Answer 应用到 RTCPeerConnection
,并开始收集本地 ICE 候选地址。setRemoteDescription
: 将从远端通过信令接收到的 Offer 或 Answer 应用到 RTCPeerConnection
。重要事件:
onicecandidate
: 当本地 ICE 代理找到一个网络候选地址(IP、端口、协议)时触发此事件,你需要将这个候选地址通过信令发送给对方。onaddstream
(已废弃): 旧版 API 中,当接收到远端的媒体流 (MediaStream
) 时触发。ontrack
(推荐使用的现代 API ): 当接收到远端的媒体轨道 (MediaStreamTrack
) 时触发此事件。这是当前标准接收音视频的方式。 整个媒体协商过程可以简化为三个步骤对应上述的四个媒体协商方法:
Annie 发起: Annie 调用 createOffer()
创建(包含她的信息)。她用 setLocalDescription()
保存这个 Offer,然后通过信令服务器发给 Steve。
Steve 回应: Steve 收到 Offer 后,用 setRemoteDescription()
保存它。然后,他调用 createAnswer()
创建回应 (Answer)(包含他的信息)。接着,他用 setLocalDescription()
保存这个 Answer,再通过信令服务器发回给 Annie。
Annie 确认: Annie 收到 Answer 后,用 setRemoteDescription()
保存它。至此,双方的信息交换完毕。
经过这三个步骤,则就完成了 P2P 通信过程中的媒体协商部分,实际上在呼叫端以及接收端调用 setLocalDescription
的同时也开始了收集各端自己的网络信息(candidate),然后各端通过监听 ontrack
事件拿到对方的视频流进而完成了整个视频通
3.常用 API API 名: navigator.mediaDevices.getUserMedia()
描述:
用于请求访问用户的媒体输入设备(如摄像头和麦克风)。它会提示用户授权。成功后,返回一个 Promise,该 Promise 解析为一个 MediaStream 对象,包含了请求的音视频轨道。可以通过 constraints 参数指定请求的媒体类型(音频/视频)以及具体要求(如分辨率、帧率)。
示例代码:
JavaScript
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 const videoElement = document .getElementById ('localVideo' );let localStream;async function startCamera ( ) { try { const constraints = { video : { width : { ideal : 640 }, height : { ideal : 480 }, }, audio : true }; localStream = await navigator.mediaDevices .getUserMedia (constraints); console .log ('成功获取本地媒体流:' , localStream); if (videoElement) { videoElement.srcObject = localStream; } } catch (error) { console .error ('getUserMedia 错误:' , error); alert (`无法访问媒体设备: ${error.name} - ${error.message} ` ); } }
API 名: RTCPeerConnection
描述:
WebRTC 的核心接口,用于建立和管理两个对等端之间的连接。通过 new RTCPeerConnection(configuration) 创建实例。configuration 对象可以包含 iceServers 列表(STUN/TURN 服务器信息)等设置。它提供了创建 Offer/Answer、设置本地/远端描述、添加/移除媒体轨道、添加 ICE 候选者、创建数据通道等方法,并提供了监听连接状态、ICE 候选者、媒体轨道等事件的接口。
示例代码:
JavaScript
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 let peerConnection;const configuration = { iceServers : [ { urls : 'stun:stun.l.google.com:19302' }, ] }; function createPeerConnection ( ) { try { peerConnection = new RTCPeerConnection (configuration); console .log ('RTCPeerConnection 已创建' ); peerConnection.onicecandidate = (event ) => { if (event.candidate ) { console .log ('生成本地 ICE Candidate:' , event.candidate ); } else { console .log ('ICE Candidate 收集完成' ); } }; peerConnection.ontrack = (event ) => { console .log ('收到远端媒体轨道:' , event.track , '关联的流:' , event.streams [0 ]); const remoteVideo = document .getElementById ('remoteVideo' ); if (remoteVideo && event.streams && event.streams [0 ]) { if (remoteVideo.srcObject !== event.streams [0 ]) { remoteVideo.srcObject = event.streams [0 ]; console .log ('已将远端流附加到 video 元素' ); } } }; peerConnection.onconnectionstatechange = () => { if (peerConnection) { console .log (`PeerConnection 连接状态变为: ${peerConnection.connectionState} ` ); } }; peerConnection.onnegotiationneeded = async () => { console .log ("需要进行协商 (onnegotiationneeded 触发)" ); }; peerConnection.ondatachannel = (event ) => { console .log ('收到远端数据通道:' , event.channel .label ); const receiveChannel = event.channel ; setupDataChannelHandlers (receiveChannel); }; } catch (error) { console .error ('创建 RTCPeerConnection 失败:' , error); } }
API 名: RTCPeerConnection.createOffer()
描述:
由发起连接的一方调用,用于创建一个包含本地媒体能力和网络地址信息的 SDP (Session Description Protocol) Offer。返回一个 Promise,该 Promise 解析为一个 RTCSessionDescription 对象(包含 type: ‘offer’ 和 sdp 字符串)。通常在调用此方法前需要先通过 addTrack() 添加好要发送的媒体轨道。
示例代码:
JavaScript
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 async function makeOffer ( ) { if (!peerConnection) { console .error ('PeerConnection 不存在' ); return ; } try { console .log ('创建 Offer...' ); const offerOptions = { offerToReceiveAudio : true , offerToReceiveVideo : true }; const offer = await peerConnection.createOffer (offerOptions); console .log ('Offer 已创建, 设置本地描述...' ); await peerConnection.setLocalDescription (offer); console .log ('本地描述已设置, 通过信令发送 Offer:' ); } catch (error) { console .error ('创建 Offer 或设置本地描述失败:' , error); } }
API 名: RTCPeerConnection.createAnswer()
描述:
由接收 Offer 的一方调用,用于创建一个 SDP Answer 来回应收到的 Offer。它会根据本地媒体能力和收到的 Offer 内容生成应答。返回一个 Promise,该 Promise 解析为一个 RTCSessionDescription 对象(包含 type: ‘answer’ 和 sdp 字符串)。在调用此方法前,必须先调用 setRemoteDescription() 将收到的 Offer 设置为远端描述。
示例代码:
JavaScript
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 async function handleOfferAndCreateAnswer (offerData ) { if (!peerConnection) { console .error ('PeerConnection 不存在' ); return ; } try { console .log ('收到 Offer, 设置远端描述...' ); const offerDescription = new RTCSessionDescription (offerData); await peerConnection.setRemoteDescription (offerDescription); console .log ('远端描述 (Offer) 已设置, 创建 Answer...' ); const answer = await peerConnection.createAnswer (); console .log ('Answer 已创建, 设置本地描述...' ); await peerConnection.setLocalDescription (answer); console .log ('本地描述 (Answer) 已设置, 通过信令发送 Answer:' ); } catch (error) { console .error ('处理 Offer 或创建/设置 Answer 失败:' , error); } }
API 名: RTCPeerConnection.setLocalDescription()
描述:
将一个 SDP 描述(Offer 或 Answer)设置为本地端的描述。这会启动 ICE Agent 开始收集本地网络候选地址。它接受一个 RTCSessionDescription 对象作为参数,并返回一个 Promise。通常在 createOffer() 或 createAnswer() 之后调用。
示例代码: (已包含在 createOffer
和 createAnswer
示例中)
JavaScript
API 名: RTCPeerConnection.setRemoteDescription()
描述:
将从远端对等方通过信令收到的 SDP 描述(Offer 或 Answer)设置为远端描述。这对于连接协商过程至关重要。它接受一个 RTCSessionDescription 对象作为参数,并返回一个 Promise。接收 Offer 时在 createAnswer() 之前调用;接收 Answer 时在 createOffer() 和 setLocalDescription() 之后调用。
示例代码:
JavaScript
API 名: RTCPeerConnection.addTrack()
描述:
将一个本地 MediaStreamTrack (通常来自 getUserMedia 返回的 MediaStream) 添加到 RTCPeerConnection 中,以便将其发送给对等方。需要传入 track 对象和它所属的 stream 对象。此操作通常需要在创建 Offer 之前完成,这样 Offer SDP 中才能包含相应的媒体描述。
示例代码:
JavaScript
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 function addLocalTracksToPeerConnection (stream ) { if (!peerConnection || !stream) { console .error ('PeerConnection 或媒体流不存在' ); return ; } console .log ('将本地轨道添加到 PeerConnection...' ); stream.getTracks ().forEach (track => { try { peerConnection.addTrack (track, stream); console .log (`轨道 ${track.kind} (${track.id} ) 已添加` ); } catch (error) { console .error (`添加轨道 ${track.kind} 失败:` , error); } }); }
API 名: RTCPeerConnection.addIceCandidate()
描述:
将在信令通道上从对等方收到的 ICE 候选者添加到 RTCPeerConnection 的 ICE Agent 中。ICE Agent 使用这些远端候选者与本地候选者进行连通性检查,以寻找最佳的通信路径。接受一个 RTCIceCandidate 对象或符合其结构的字典作为参数,并返回一个 Promise。
示例代码:
JavaScript
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 async function handleRemoteIceCandidate (candidateData ) { if (!peerConnection) { console .warn ('PeerConnection 不存在,无法添加 ICE Candidate' ); return ; } if (!candidateData) { console .log ("收到 null ICE candidate,忽略。" ); return ; } try { const candidate = new RTCIceCandidate (candidateData); if (peerConnection.signalingState !== 'closed' ) { await peerConnection.addIceCandidate (candidate); } else { console .warn ("PeerConnection 已关闭,忽略收到的 ICE candidate。" ); } } catch (error) { if (error.name === 'OperationError' || error.message .includes ('candidate type' )) { console .warn (`添加 ICE Candidate 时忽略错误: ${error.message} ` ); } else { console .error ('添加远端 ICE Candidate 失败:' , error, 'Candidate:' , candidateData); } } }
API 名: RTCPeerConnection.createDataChannel()
描述:
创建一个 RTCDataChannel,用于在对等方之间传输任意数据(字符串或二进制)。发起方调用此方法创建通道。接收方则通过监听 RTCPeerConnection 的 ondatachannel 事件来获取对方创建的通道。可以指定一个标签(字符串)和一些选项(如 ordered:是否保证顺序,maxRetransmits:最大重传次数等)。
示例代码 (创建方):
JavaScript
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 let dataChannel;function setupDataChannel ( ) { if (!peerConnection) { console .error ("PeerConnection 不存在,无法创建数据通道" ); return ; } try { const dataChannelOptions = { ordered : true , }; dataChannel = peerConnection.createDataChannel ("myDataChannel" , dataChannelOptions); console .log (`数据通道 "${dataChannel.label} " 已创建` ); setupDataChannelHandlers (dataChannel); } catch (e) { console .error ("创建数据通道失败:" , e); } } function setupDataChannelHandlers (channel ) { channel.onopen = () => { console .log (`数据通道 "${channel.label} " 已打开` ); status.value = '数据通道已连接' ; }; channel.onmessage = (event ) => { console .log (`收到数据通道消息:` , event.data ); }; channel.onclose = () => { console .log (`数据通道 "${channel.label} " 已关闭` ); status.value = '数据通道已关闭' ; }; channel.onerror = (error ) => { console .error (`数据通道 "${channel.label} " 错误:` , error); errorMessage.value = `数据通道错误: ${error} ` ; }; }
示例代码 (接收方,在 createPeerConnection
的 ondatachannel
回调中):
JavaScript
API 名: RTCDataChannel.send()
描述:
通过已建立的 RTCDataChannel 发送数据给对等方。可以发送字符串、Blob、ArrayBuffer 或 ArrayBufferView。
示例代码:
JavaScript
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 function sendDataChannelMessage (message ) { if (dataChannel && dataChannel.readyState === 'open' ) { try { dataChannel.send (message); console .log ('通过数据通道发送消息:' , message); } catch (error) { console .error ("发送数据失败:" , error); } } else { console .warn ('数据通道未打开,无法发送消息。' ); } }
4. 项目实践 (Vue3 + Flask-SocketIO) 1.项目初始化 项目采用 vue3+ts
,运行如下命令创建项目(例如 webrtc-client
):
1 2 npm create vite@latest webrtc-client -- --template vue-ts cd webrtc-client
安装 Tailwind CSS, PostCSS, Autoprefixer 和 DaisyUI (作为开发依赖):
1 npm install -D tailwindcss postcss autoprefixer daisyui
初始化 Tailwind CSS 配置文件:
1 2 npx tailwindcss init -p
如果执行成功,会创建 tailwind.config.js
和 postcss.config.js
。如果报错或你使用的版本不需要,请直接创建或修改这些文件。
配置 tailwind.config.js:
用以下内容覆盖 tailwind.config.js 文件:
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 export default { content : [ "./index.html" , "./src/**/*.{vue,js,ts,jsx,tsx}" , ], theme : { extend : { colors : { tropical : { primary : '#00C9A7' , secondary : '#FFC75F' , accent : '#FF6B6B' , neutral : '#845EC2' , 'base-100' : '#F9F9F9' , 'base-200' : '#eeeeee' , 'base-300' : '#dddddd' , info : '#4D8076' , success : '#7BBA7A' , warning : '#FFB347' , error : '#FF6B6B' , } } }, }, plugins : [require ("daisyui" )], daisyui : { themes : [{ tropicalLight : { "primary" : "#00C9A7" , "secondary" : "#FFC75F" , "accent" : "#FF6B6B" , "neutral" : "#845EC2" , "base-100" : "#F9F9F9" , "base-200" : "#eeeeee" , "base-300" : "#dddddd" , "info" : "#4D8076" , "success" : "#7BBA7A" , "warning" : "#FFB347" , "error" : "#FF6B6B" , }, }, "light" ], darkTheme : "light" , logs : true , }, }
创建 Tailwind CSS 基础文件:
在 src/assets/style/ 目录下创建 common.css (或其他名称如 main.css) 文件,添加以下内容:
1 2 3 4 @tailwind base;@tailwind components;@tailwind utilities;
在 main.ts 中引入 CSS:
修改 src/main.ts 文件,确保引入了上述 CSS 文件:
1 2 3 4 5 6 import { createApp } from 'vue' import './assets/style/common.css' import App from './App.vue' createApp (App ).mount ('#app' )
安装 socket.io-client
:
1 npm install socket.io-client
####### 前端基础组件 (App.vue)
用以下内容 完全替换 src/App.vue
文件的内容:
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 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 <script lang="ts" setup> import { ref, onMounted, onUnmounted, computed } from 'vue' import { Socket } from 'socket.io-client' // --- 响应式状态 --- const socket = ref<Socket | undefined>() // Socket.IO 实例 const called = ref(false) // 当前用户是否是接收方 const caller = ref(false) // 当前用户是否是发起方 const calling = ref(false) // 是否处于"呼叫中"状态 const communicating = ref(false) // 是否正在视频通话中 const localVideo = ref<HTMLVideoElement | null>(null) // 本地视频元素引用 const remoteVideo = ref<HTMLVideoElement | null>(null) // 远端视频元素引用 const currentTheme = ref('tropicalLight') // 当前主题 const errorMessage = ref('') // 错误信息 const roomId = ref('room001') // 房间ID const status = ref('空闲') // 连接状态 const stats = ref<any>(null) // WebRTC 统计信息 // WebRTC 配置 const iceServers = [ 'stun:stun.l.google.com:19302', 'stun:stun1.l.google.com:19302', 'stun:stun2.l.google.com:19302', 'stun:stun3.l.google.com:19302', 'stun:stun4.l.google.com:19302' ] // --- 状态组 - 用于计算属性 --- const successStates = ['已连接', '通话中'] const infoStates = ['连接中', '连接信令服务器...', '正在连接...', '呼叫中...', '收到呼叫邀请', '对方已接受,正准备连接...'] const errorStates = ['错误'] // --- 计算属性 --- const statusClass = computed(() => { if (successStates.includes(status.value)) return 'badge-success' if (infoStates.includes(status.value)) return 'badge-info' if (errorStates.includes(status.value)) return 'badge-error' return 'badge-neutral' // 默认(空闲、已断开等状态) }) // --- 生命周期钩子 --- onMounted(() => { console.log("App 组件已挂载") }) onUnmounted(() => { console.log("App 组件即将卸载") }) // --- 业务方法 --- function callRemote() { console.log('发起视频 (待实现)') } function acceptCall() { console.log('同意视频邀请 (待实现)') } function hangUp() { console.log('挂断视频 (待实现)') } // --- 辅助函数 --- function cleanupRTC() { console.log("清理 WebRTC (待实现)") } function resetStates() { console.log("重置状态 (待实现)") } </script> <template> <div class="min-h-screen bg-base-100 transition-colors duration-300 font-sans" :data-theme="currentTheme"> <div class="flex flex-col items-center justify-between p-4 md:p-8 min-h-screen"> <!-- 标题卡片 --> <div class="card w-full max-w-3xl bg-base-200 shadow-xl mb-4"> <div class="card-body items-center text-center"> <h2 class="card-title text-xl md:text-2xl">简单易用的视频通话解决方案</h2> <p class="text-sm md:text-base">使用WebRTC技术,实现高质量的视频通话</p> <p class="mt-2"> 状态: <span :class="statusClass" class="font-semibold badge badge-md md:badge-lg text-white">{{ status }}</span> </p> <p v-if="errorMessage" class="text-error text-sm mt-1">{{ errorMessage }}</p> <p class="text-sm mt-1 text-gray-500">房间号: {{ roomId }}</p> </div> </div> <!-- 视频区域 --> <div class="relative w-full max-w-3xl aspect-video bg-black rounded-lg overflow-hidden shadow-lg"> <!-- 本地视频 --> <video ref="localVideo" autoplay playsinline muted class="absolute inset-0 w-full h-full object-cover z-0 main-video"></video> <!-- 远程视频 --> <video ref="remoteVideo" autoplay playsinline class="w-1/4 max-w-[160px] h-auto absolute bottom-4 right-4 object-cover border-2 border-base-300 rounded shadow-md z-10 transition-opacity duration-300 remote-video" :class="{ 'opacity-0 pointer-events-none': !communicating }"></video> <!-- 等待对方接听弹窗 --> <dialog id="waiting_modal" class="modal" :open="caller && calling"> <div class="modal-box"> <h3 class="text-lg font-bold">等待对方接听...</h3> <p class="py-4">请耐心等待对方接受您的视频邀请</p> <div class="modal-action"> <button class="btn btn-error" @click="hangUp">取消</button> </div> </div> <form method="dialog" class="modal-backdrop"><button @click="hangUp">关闭</button></form> </dialog> <!-- 接听邀请界面 --> <div v-if="called && calling" class="absolute inset-0 flex flex-col items-center justify-center bg-black bg-opacity-70 text-white z-20"> <p class="mb-4 text-lg">收到视频邀请...</p> <div class="flex gap-4"> <button @click="hangUp" class="btn btn-circle btn-error btn-lg" aria-label="拒绝">✗</button> <button @click="acceptCall" class="btn btn-circle btn-success btn-lg" aria-label="接受">✓</button> </div> </div> </div> <!-- 控制按钮 --> <div class="flex gap-4 mt-4"> <button class="btn btn-primary text-white" @click="callRemote" > 发起视频 </button> <button class="btn btn-error btn-outline" @click="hangUp" > 挂断 </button> </div> <!-- 统计信息 --> <div v-if="stats" class="mt-4 p-2 bg-base-300 rounded shadow w-full max-w-3xl"> <h3 class="text-sm font-semibold mb-1">WebRTC 统计</h3> <pre class="text-xs overflow-auto max-h-24 bg-base-100 p-1 rounded">{{ JSON.stringify(stats, null, 2) }}</pre> </div> <footer class="mt-auto pt-4 text-center text-xs text-base-content/70"> 请确保 Python 信令服务器正在运行。 </footer> </div> </div> </template> <style> /* 基础布局样式 */ .content-container { display: flex; flex-direction: column; align-items: center; justify-content: flex-start; padding: 1rem; min-height: 100vh; } /* 视频容器样式 */ .video-container { position: relative; width: 100%; aspect-ratio: 16 / 9; background-color: #000; margin-bottom: 1rem; } /* 视频元素共用样式 */ .main-video, .remote-video { display: block; background-color: #222; } .main-video { width: 100%; height: 100%; object-fit: cover; } .remote-video { width: 25%; height: auto; max-width: 180px; position: absolute; bottom: 1rem; right: 1rem; border: 2px solid rgba(255, 255, 255, 0.5); border-radius: 0.375rem; box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); z-index: 10; transition: opacity 0.3s ease-in-out; } .remote-video.hidden { opacity: 0; pointer-events: none; } </style>
后端基础设置(server_flask.py)
安装依赖:
在你的 Python 后端项目目录中(例如 python-flask-server),激活虚拟环境,运行:
1 pip install Flask Flask-SocketIO eventlet
创建 server_flask.py (初始代码):
创建 server_flask.py 文件,并写入以下初始代码:
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 from flask import Flask, request from flask_socketio import SocketIO, emit import loggingimport os logging.basicConfig(level=logging.INFO, format ='%(asctime)s %(levelname)s:%(name)s: %(message)s' ) logger = logging.getLogger("flask_socketio_server" ) app = Flask(__name__) app.config['SECRET_KEY' ] = os.urandom(24 ) socketio = SocketIO(app, cors_allowed_origins="*" , async_mode='eventlet' ) logger.info("Flask-SocketIO 服务器准备就绪..." ) @socketio.on('connect' ) def handle_connect (): """处理新的客户端连接""" sid = request.sid logger.info(f"客户端已连接: sid={sid} " ) try : emit('connectionSuccess' , room=sid) logger.info(f"已向 sid={sid} 发送 connectionSuccess 事件" ) except Exception as e: logger.error(f"向 sid={sid} 发送 connectionSuccess 时出错: {e} " ) @socketio.on('disconnect' ) def handle_disconnect (): """处理客户端断开连接""" sid = request.sid logger.info(f"客户端已断开连接: sid={sid} " ) if __name__ == '__main__' : logger.info("准备启动 Flask-SocketIO 服务器 on port 3000..." ) socketio.run(app, host='0.0.0.0' , port=3000 , debug=True , allow_unsafe_werkzeug=True )
现在,项目的基本骨架已经搭建完毕。前端有了界面模板和初始状态,后端有了一个可以接受 Socket.IO 连接的基础服务器。
2.实现连接与房间加入 目标:
前端连接到 Flask-SocketIO 后端,处理连接/错误/断开事件。 连接成功后,前端自动发送 joinRoom
事件。 后端处理 joinRoom
事件,管理房间成员。 后端在客户端断开时,处理其离开房间的逻辑。 1. 后端修改 (server_flask.py
)
新增: 添加了用于存储房间信息的 rooms_data
和 sid_room
全局字典。新增: 添加了处理客户端 joinRoom
事件的 handle_join_room
函数。修改: 更新了 handle_disconnect
函数,加入了客户端离开房间的清理逻辑。请在 server_flask.py
中添加或修改以下代码:
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 74 75 76 77 78 79 80 81 82 from flask_socketio import SocketIO, emit, leave_room,join_room rooms_data: dict [str , set [str ]] = {} sid_room: dict [str , str ] = {} @socketio.on('joinRoom' ) def handle_join_room (data ): """处理客户端加入房间的请求""" sid = request.sid room_id = data.get('roomId' ) if not room_id: return logger.warning(f"sid={sid} joinRoom缺少roomId" ) if sid in sid_room: old_room_id = sid_room[sid] if old_room_id != room_id: logger.info(f"sid={sid} 正在离开旧房间: {old_room_id} " ) try : leave_room(old_room_id, sid=sid) if old_room_id in rooms_data and sid in rooms_data[old_room_id]: rooms_data[old_room_id].discard(sid) if not rooms_data[old_room_id]: del rooms_data[old_room_id]; logger.info(f"房间已移除(空): {old_room_id} " ) socketio.emit('peerLeft' , {'peerId' : sid}, room=old_room_id) except Exception as e: logger.error(f"处理sid={sid} 离开旧房间{old_room_id} 出错: {e} " ) if sid in sid_room: del sid_room[sid] if sid not in sid_room or sid_room[sid] != room_id: logger.info(f"sid={sid} 正在加入新房间: {room_id} " ) try : join_room(room_id, sid=sid) if room_id not in rooms_data: rooms_data[room_id] = set () rooms_data[room_id].add(sid) sid_room[sid] = room_id logger.info(f"sid={sid} 已成功加入房间: {room_id} " ) socketio.emit('peerJoined' , {'peerId' : sid}, room=room_id, skip_sid=sid) except Exception as e: logger.error(f"处理sid={sid} 加入新房间{room_id} 出错: {e} " ) else : logger.info(f"sid={sid} 已在房间 {room_id} 中。" ) @socketio.on('disconnect' ) def handle_disconnect (): """处理客户端断开连接""" sid = request.sid logger.info(f"客户端准备断开连接: sid={sid} " ) if sid in sid_room: room_id = sid_room.pop(sid) logger.info(f"客户端 sid={sid} 正在从房间 {room_id} 断开..." ) try : if room_id in rooms_data: rooms_data[room_id].discard(sid) logger.info(f"sid={sid} 已从 rooms_data[{room_id} ] 移除" ) if not rooms_data[room_id]: del rooms_data[room_id] logger.info(f"房间已移除 (空): {room_id} " ) else : logger.info(f"向房间 {room_id} 广播 peerLeft 事件 (来自 {sid} )" ) socketio.emit('peerLeft' , {'peerId' : sid}, room=room_id) except Exception as e: logger.error(f"处理 sid={sid} 离开房间 {room_id} (disconnect时) 出错: {e} " ) else : logger.info(f"客户端 sid={sid} 断开连接时不在任何已记录的房间中。" ) logger.info(f"客户端 sid={sid} 清理完成。" )
2. 前端修改 (src/App.vue
)
修改: 更新 onMounted
钩子,在连接成功后发送 joinRoom
事件,并添加必要的错误和断开连接处理。添加可选的 peerJoined
和 peerLeft
监听器。修改: 更新 onUnmounted
钩子,确保断开 Socket.IO 连接。新增: 添加 cleanupWebSocket
和 resetStates
辅助函数(或确保它们已定义)。请在 src/App.vue
的 <script setup>
部分替换或添加以下代码:
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 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 const cleanupWebSocket = ( ) => { if (socket.value ) { console .log ("清理 Socket.IO 连接..." ); socket.value .off (); if (socket.value .connected ) { socket.value .disconnect (); console .log ("Socket.IO 已断开。" ); } socket.value = null ; } }; const resetStates = (keepError : boolean = false ) => { console .log ("重置状态..." ); caller.value = false ; called.value = false ; calling.value = false ; communicating.value = false ; isConnecting.value = false ; if (!keepError) { errorMessage.value = '' ; } if (status.value !== '错误' ) { setStatus (socket.value ?.connected ? '已连接信令服务器' : '空闲' ); } }; const setStatus = (newStatus : string , errorMsg : string = '' ) => { status.value = newStatus; errorMessage.value = errorMsg; console .log (`状态更新: ${newStatus} ${errorMsg ? ' - ' + errorMsg : '' } ` ); if (newStatus === '错误' ) { isConnecting.value = false ; } }; const hangUpCleanup = (isUserAction : boolean = true ) => { console .log (`执行本地挂断清理 (用户操作: ${isUserAction} )...` ); cleanupRTC (); resetStates (!isUserAction); if (isUserAction && status.value !== '错误' ) { setStatus ('已挂断' ); } else if (!isUserAction && status.value !== '错误' ) { setStatus ('对方已挂断' ); } setTimeout (() => { if (status.value === '已挂断' || status.value === '对方已挂断' ) { setStatus (socket.value ?.connected ? '已连接信令服务器' : '空闲' ); } }, 1500 ); }; onMounted (() => { console .log ("App 组件已挂载,开始连接信令服务器..." ); const socketInstance = io ('http://localhost:3000' , { transports : ['websocket' ] }); socketInstance.on ("connect" , () => { console .log ("Socket.IO 连接成功, sid:" , socketInstance.id ); setStatus ('已连接信令服务器' ); if (socket.value ) { socket.value .emit ('joinRoom' , { roomId : roomId.value }); console .log (`已发送加入房间请求: ${roomId.value} ` ); } else { console .error ("Socket 实例未就绪无法加入房间" ); } }); socketInstance.on ("connectionSuccess" , () => { console .log ("收到来自服务器的 connectionSuccess 确认" ); }); socketInstance.on ("connect_error" , (err ) => { console .error ("连接错误:" , err); setStatus ('错误' , `无法连接信令服务器: ${err.message} ` ); cleanupWebSocket (); cleanupRTC (); }); socketInstance.on ("disconnect" , (reason ) => { console .warn ("与服务器断开连接:" , reason); if (status.value !== '错误' && status.value !== '已挂断' ) { setStatus ('已断开' ); } resetStates (status.value === '错误' ); cleanupRTC (); socket.value = null ; }); socketInstance.on ("peerJoined" , (data : { peerId: string } ) => { console .log (`通知: 对等端 ${data.peerId} 加入了房间` ); }); socketInstance.on ("peerLeft" , (data : { peerId: string } ) => { console .log (`通知: 对等端 ${data.peerId} 离开了房间` ); if (communicating.value ) { console .warn ("通话中的对方已离开房间!" ); hangUpCleanup (false ); setStatus ("对方已离开" ); } }); socketInstance.on ("callRemote" , (data ) => { }); socketInstance.on ("acceptCall" , (data ) => { }); socketInstance.on ("hangUp" , (data ) => { }); socketInstance.on ("offer" , async (data) => { }); socketInstance.on ("answer" , async (data) => { }); socketInstance.on ("candidate" , async (data) => { }); socket.value = socketInstance; }); onUnmounted (() => { console .log ("组件即将卸载,断开连接并清理..." ); hangUpCleanup (false ); cleanupWebSocket (); }); const getLocalStream = async (): Promise <MediaStream | undefined > => { console .warn ("getLocalStream 待实现!" ); return undefined ; }; const createPeerConnectionAndAddTracks = (): RTCPeerConnection | null => { console .warn ("createPeerConnectionAndAddTracks 待实现!" ); return null ; }; const callRemote = ( ) => { console .log ('发起视频 (待实现)' ); caller.value = true ; calling.value = true ; }; const acceptCall = ( ) => { console .log ('同意视频邀请 (待实现)' ); }; const hangUp = ( ) => { console .log ('挂断视频 (待实现)' ); caller.value = false ; calling.value = false ; }; </script>
测试:
重启后端 (python server_flask.py
)。 用 两个 浏览器窗口打开前端页面。 检查后端日志: 确认两个客户端都连接成功,并且都加入了 room001
。检查第一个窗口的控制台: 应该能看到第二个窗口加入房间的 peerJoined
通知。关闭第二个窗口:检查后端日志:确认第二个客户端断开连接并已从房间移除。 检查第一个窗口的控制台:应该能看到第二个窗口离开房间的 peerLeft
通知。 现在,基本的连接和房间管理功能已经完成并且前后端能够正确交互了。
3.实现呼叫信令 (请求/接受/挂断) 目标:
用户点击“发起视频”按钮时,前端发送 callRemote
事件。 房间内其他用户收到 callRemote
事件,更新 UI 显示“收到呼叫邀请”。 收到邀请的用户点击“接受”按钮,前端发送 acceptCall
事件。 发起方收到 acceptCall
事件,更新 UI 显示“对方已接受”。 任何一方点击“挂断”或收到 hangUp
事件,都能结束当前呼叫/通话状态并清理。 1. 后端修改 (server_flask.py
)
在 server_flask.py
的 “Socket.IO 事件处理” 部分,添加 以下三个新的事件处理器,用于转发呼叫相关的信令:
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 @socketio.on('callRemote' ) def handle_call_remote (data ): """处理发起呼叫请求,并转发给同房间其他人""" sid = request.sid room_id = data.get('roomId' ) logger.info(f"收到来自 sid={sid} 的 callRemote 请求,房间: {room_id} " ) if room_id and room_id in rooms_data: emit('callRemote' , {'callerId' : sid}, room=room_id, skip_sid=sid) logger.info(f"已将 callRemote 事件转发给房间 {room_id} (除 {sid} 外)" ) else : logger.warning(f"无法转发 callRemote:房间 {room_id} 无效或用户不在房间内。" ) @socketio.on('acceptCall' ) def handle_accept_call (data ): """处理接受呼叫请求,并转发给发起方(或其他同房间的人)""" sid = request.sid room_id = data.get('roomId' ) logger.info(f"收到来自 sid={sid} 的 acceptCall 请求,房间: {room_id} " ) if room_id and room_id in rooms_data: emit('acceptCall' , {'accepterId' : sid}, room=room_id, skip_sid=sid) logger.info(f"已将 acceptCall 事件转发给房间 {room_id} (除 {sid} 外)" ) else : logger.warning(f"无法转发 acceptCall:房间 {room_id} 无效或用户不在房间内。" ) @socketio.on('hangUp' ) def handle_hang_up (data ): """处理挂断请求,并转发给同房间其他人""" sid = request.sid room_id = data.get('roomId' ) logger.info(f"收到来自 sid={sid} 的 hangUp 请求,房间: {room_id} " ) if room_id and room_id in rooms_data: emit('hangUp' , {'peerId' : sid}, room=room_id, skip_sid=sid) logger.info(f"已将 hangUp 事件转发给房间 {room_id} (除 {sid} 外)" ) else : logger.warning(f"无法转发 hangUp:房间 {room_id} 无效或用户不在房间内。" )
2. 前端修改 (src/App.vue
)
在 <script setup lang="ts">
内部:
新增 sendSignalingMessage
辅助函数。替换 callRemote
, acceptCall
, hangUp
的占位实现,加入状态更新和 sendSignalingMessage
调用。替换 resetStates
和 hangUpCleanup
的占位实现。在 onMounted
的 socketInstance
事件监听器中,替换 callRemote
, acceptCall
, hangUp
的占位监听器,添加处理逻辑。 请在 src/App.vue
的 <script setup>
部分添加或替换以下代码:
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 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 const setStatus = (newStatus : string , errorMsg : string = '' ) => { status.value = newStatus; errorMessage.value = errorMsg; console .log (`状态更新: ${newStatus} ${errorMsg ? ' - ' + errorMsg : '' } ` ); if (newStatus === '错误' ) { isConnecting.value = false ; } }; const handleError = (context : string , error : any ): null => { console .error (`${context} 出错:` , error); setStatus ('错误' , `${context} 失败: ${error instanceof Error ? error.message : String (error)} ` ); hangUpCleanup (false ); return null ; }; const sendSignalingMessage = (type : string , payload : Record <string , any > = {} ) => { if (socket.value && socket.value .connected ) { const message = { type , roomId : roomId.value , ...payload }; console .log (`发送信令: ${type } ` , message); socket.value .emit (type , message); } else { console .warn (`无法发送信令 '${type } ':Socket.IO 未连接。` ); setStatus ('错误' , '信令服务器连接已断开' ); } }; const cleanupRTC = ( ) => { };const cleanupWebSocket = ( ) => { };const resetStates = (keepError : boolean = false ) => { console .log ("重置状态..." ); caller.value = false ; called.value = false ; calling.value = false ; communicating.value = false ; isConnecting.value = false ; if (!keepError) { errorMessage.value = '' ; } if (status.value !== '错误' ) { setStatus (socket.value ?.connected ? '已连接信令服务器' : '空闲' ); } }; const hangUpCleanup = (isUserAction : boolean = true ) => { console .log (`执行本地挂断清理 (用户操作: ${isUserAction} )...` ); cleanupRTC (); resetStates (!isUserAction); const finalStatus = isUserAction ? '已挂断' : '对方已挂断' ; if (status.value !== '错误' ) { setStatus (finalStatus); setTimeout (() => { if (status.value === finalStatus) { setStatus (socket.value ?.connected ? '已连接信令服务器' : '空闲' ); } }, 1500 ); } }; const callRemote = async ( ) => { console .log ('发起视频请求...' ); if (!socket.value ?.connected ) { return setStatus ('错误' , "未连接到信令服务器" ); } if (communicating.value || calling.value || caller.value || called.value ) { return console .warn ("已在通话或呼叫中" ); } console .log ("(模拟)获取本地流..." ); caller.value = true ; calling.value = true ; setStatus ('呼叫中...' ); sendSignalingMessage ('callRemote' ); }; const acceptCall = async ( ) => { console .log ('接受视频邀请...' ); if (!socket.value ?.connected ) { return setStatus ('错误' , "未连接到信令服务器" ); } if (!called.value || !calling.value ) { return console .warn ("状态不符,无法接受" ); } console .log ("(模拟)获取本地流..." ); calling.value = false ; setStatus ('正在连接...' ); sendSignalingMessage ('acceptCall' ); }; const hangUp = ( ) => { console .log ('请求挂断视频...' ); sendSignalingMessage ('hangUp' ); hangUpCleanup (true ); }; onMounted (() => { const connectWebSocket = ( ) => { const socketInstance = io ('http://localhost:3000' , { transports : ['websocket' ] }); socketInstance.on ("connect" , () => { console .log ("Socket.IO 连接成功, sid:" , socketInstance.id ); setStatus ('已连接信令服务器' ); sendSignalingMessage ('joinRoom' ); }); socketInstance.on ("connectionSuccess" , () => { console .log ("收到 connectionSuccess 确认" ); }); socketInstance.on ("connect_error" , (err ) => { handleError ("Socket.IO 连接" , err); }); socketInstance.on ("disconnect" , (reason ) => { console .warn ("与服务器断开:" , reason); if (status.value !== '错误' && status.value !== '已挂断' ) setStatus ('已断开' ); hangUpCleanup (false ); }); socketInstance.on ("peerJoined" , (data ) => { console .log (`通知: ${data.peerId} 加入` ); }); socketInstance.on ("peerLeft" , (data ) => { console .log (`通知: ${data.peerId} 离开` ); if (communicating.value ){ hangUpCleanup (false ); setStatus ('对方已离开' );}}); socketInstance.on ("errorMsg" , (data ) => { handleError ("服务器信令" , data.message || "未知错误" ); }); socketInstance.on ("callRemote" , (data ) => { console .log ("收到呼叫请求, 来自:" , data?.callerId ); if (communicating.value || calling.value || caller.value || called.value ) { console .warn ("忙线中,忽略呼叫" ); return ; } called.value = true ; calling.value = true ; setStatus ('收到呼叫邀请' ); }); socketInstance.on ("acceptCall" , async (data) => { console .log ("对方已接受呼叫, 来自:" , data?.accepterId ); if (caller.value && calling.value ) { calling.value = false ; setStatus ('对方已接受,正准备连接...' ); console .log ("下一步:创建并发送 Offer (待实现)" ); } else { console .warn (`收到 acceptCall 但状态不符, caller=${caller.value} , calling=${calling.value} ` ); } }); socketInstance.on ("hangUp" , (data ) => { console .log ("收到对方挂断通知, 来自:" , data?.peerId ); hangUpCleanup (false ); }); socketInstance.on ("offer" , async (data) => { console .log ("收到 Offer (待处理)..." ); }); socketInstance.on ("answer" , async (data) => { console .log ("收到 Answer (待处理)..." ); }); socketInstance.on ("candidate" , async (data) => { console .log ("收到 Candidate (待处理)..." ); }); socket.value = socketInstance; }; connectWebSocket (); }); onUnmounted (() => { console .log ("组件即将卸载,断开连接并清理..." ); hangUp (); }); </script>
测试:
重复上一步的测试流程:
重启后端 (python server_flask.py
)。 用两个浏览器窗口打开前端。 窗口 A 点击“发起视频”。检查状态和日志。 窗口 B 收到邀请,点击“接受”。检查双方状态和日志。 任一窗口点击“挂断”。检查双方状态是否正确重置,以及日志。 现在,应用应该能够正确地处理发起、接受和挂断通话的信令流程了,并且 UI 状态会随之更新。
4.获取媒体流、创建 PeerConnection 并交换 SDP 目标:
获取媒体: 在发起或接受呼叫时,通过 getUserMedia
请求摄像头和麦克风权限,获取本地 MediaStream
。创建连接对象: 初始化 RTCPeerConnection
。添加轨道: 将本地 MediaStream
中的音视频轨道添加到 RTCPeerConnection
。SDP 交换:发起方: 收到对方接受 (acceptCall
) 后,创建 Offer,设为本地描述,发送给对方。接收方: 收到 Offer 后,设为远端描述,创建 Answer,设为本地描述,发送给对方。发起方: 收到 Answer 后,设为远端描述。 1. 后端修改 (server_flask.py
)
在 server_flask.py
的 “Socket.IO 事件处理” 部分,添加 用于转发 offer
和 answer
消息的事件处理器:
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 @socketio.on('offer' ) def handle_offer (data ): """处理收到的 Offer SDP,并转发给同房间其他人""" sid = request.sid room_id = data.get('roomId' ) sdp = data.get('sdp' ) offer_type_from_client = data.get('offerType' , 'offer' ) logger.info(f"收到来自 sid={sid} 的 Offer,房间: {room_id} " ) if room_id and room_id in rooms_data and sdp: emit('offer' , {'senderId' : sid, 'sdp' : sdp, 'offerType' : offer_type_from_client}, room=room_id, skip_sid=sid) logger.info(f"已将 Offer 事件转发给房间 {room_id} (除 {sid} 外)" ) else : logger.warning(f"无法转发 Offer:房间 {room_id} 无效、用户不在房间或缺少SDP。" ) @socketio.on('answer' ) def handle_answer (data ): """处理收到的 Answer SDP,并转发给同房间其他人""" sid = request.sid room_id = data.get('roomId' ) sdp = data.get('sdp' ) answer_type_from_client = data.get('answerType' , 'answer' ) logger.info(f"收到来自 sid={sid} 的 Answer,房间: {room_id} " ) if room_id and room_id in rooms_data and sdp: emit('answer' , {'senderId' : sid, 'sdp' : sdp, 'answerType' : answer_type_from_client}, room=room_id, skip_sid=sid) logger.info(f"已将 Answer 事件转发给房间 {room_id} (除 {sid} 外)" ) else : logger.warning(f"无法转发 Answer:房间 {room_id} 无效、用户不在房间或缺少SDP。" )
2. 前端修改 (src/App.vue
)
在 <script setup lang="ts">
内部:
替换 getLocalStream
的占位实现。替换 createPeerConnectionAndAddTracks
的占位实现(包含事件监听器框架)。修改 callRemote
和 acceptCall
方法以调用 getLocalStream
和(对于 acceptCall
)createPeerConnectionAndAddTracks
。新增 createOfferAndSend
, handleOfferAndCreateAnswer
, handleAnswer
三个辅助函数。修改 onMounted
中的 acceptCall
监听器,使其调用 createOfferAndSend
。新增 offer
和 answer
事件的监听器。修改 cleanupRTC
函数以确保停止本地流。以下是需要替换或添加到 App.vue
中 <script setup>
部分的代码:
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 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 const getLocalStream = async (): Promise <MediaStream | undefined > => { console .log ("尝试获取本地媒体流..." ) setStatus ('请求摄像头权限...' , '' ); try { if (localStream.value ) { localStream.value .getTracks ().forEach (track => track.stop ()); } const stream = await navigator.mediaDevices .getUserMedia ({ audio : true , video : { width : 640 , height : 480 } }); console .log ('成功获取本地媒体流:' , stream.id ); localStream.value = stream; if (localVideo.value ) { localVideo.value .srcObject = stream; localVideo.value .play ().catch (e => console .error ("本地视频播放失败:" , e)); setStatus ("本地视频已就绪" ) return stream; } return stream; } catch (error : any ) { handleError ("获取媒体流" , error); return undefined ; } }; const createPeerConnectionAndAddTracks = (): RTCPeerConnection | null => { console .log ("创建 PeerConnection 并添加轨道..." ); if (!localStream.value ) { return handleError ('创建 PC' , '本地流无效' ); } try { cleanupRTC (); const pcInstance = new RTCPeerConnection (pcConfig); pcInstance.onicecandidate = (event ) => { if (event.candidate ) { console .log ('步骤5将处理: 发送本地 ICE Candidate...' ); } else { console .log ("本地 ICE Candidate 收集完成。" ); } }; pcInstance.ontrack = (event ) => { console .log ('步骤6将处理: 收到远端轨道...' ); }; pcInstance.onconnectionstatechange = () => { if (!peer.value ) return ; const state = peer.value .connectionState ; console .log (`PeerConnection 连接状态改变: ${state} ` ); switch (state) { case 'connecting' : setStatus ('连接中' ); isConnecting.value = true ; isConnected.value = false ; break ; case 'connected' : setStatus ('已连接' ); isConnecting.value = false ; isConnected.value = true ; break ; case 'disconnected' : setStatus ('已断开' ); isConnected.value = false ; isConnecting.value = false ; break ; case 'failed' : handleError ('WebRTC 连接' , '连接失败' ); break ; case 'closed' : if (status.value !== '错误' && status.value !== '已挂断' && status.value !== '对方已挂断' ) setStatus ('已断开' ); isConnected.value = false ; isConnecting.value = false ; break ; } }; localStream.value .getTracks ().forEach (track => { try { pcInstance.addTrack (track, localStream.value !); } catch (e) { console .error (`添加本地 ${track.kind} 轨道失败:` , e); } }); console .log ("本地轨道已添加" ); peer.value = pcInstance; return pcInstance; } catch (e) { return handleError ('创建 PeerConnection' , e); } }; const createOfferAndSend = async ( ) => { console .log ("准备创建并发送 Offer..." ); if (!peer.value ) { return handleError ('创建 Offer' , 'PeerConnection 未创建' ); } try { console .log ('创建 Offer...' ); const offer = await peer.value .createOffer ({ offerToReceiveAudio : true , offerToReceiveVideo : true }); console .log ('设置本地 Offer 描述...' ); if (peer.value .signalingState !== 'stable' && peer.value .signalingState !== 'have-remote-offer' ) { throw new Error (`创建 Offer 时信令状态异常: ${peer.value.signalingState} ` ); } await peer.value .setLocalDescription (offer); console .log ('通过信令服务器发送 Offer...' ); sendSignalingMessage ('offer' , { sdp : peer.value .localDescription ?.sdp , offerType : peer.value .localDescription ?.type }); } catch (error) { handleError ('创建或发送 Offer' , error); } }; const handleOfferAndCreateAnswer = async (offerData : { sdp: string , offerType: RTCSdpType } ) => { console .log ("准备处理 Offer 并创建 Answer..." ); if (!peer.value ) { return handleError ('处理 Offer' , 'PeerConnection 未创建' ); } try { console .log ('设置远端 Offer 描述...' ); if (peer.value .signalingState !== 'stable' && peer.value .signalingState !== 'have-local-offer' ) { console .warn (`设置远端 Offer 时信令状态异常: ${peer.value.signalingState} ` ); } await peer.value .setRemoteDescription (new RTCSessionDescription ({ type : 'offer' , sdp : offerData.sdp })); console .log ('创建 Answer...' ); const answer = await peer.value .createAnswer (); console .log ('设置本地 Answer 描述...' ); await peer.value .setLocalDescription (answer); console .log ('通过信令服务器发送 Answer...' ); sendSignalingMessage ('answer' , { sdp : peer.value .localDescription ?.sdp , answerType : peer.value .localDescription ?.type }); } catch (error) { handleError ('处理 Offer 或创建/发送 Answer' , error); } }; const handleAnswer = async (answerData : { sdp: string , answerType: RTCSdpType } ) => { console .log ("准备处理 Answer..." ); if (!peer.value ) { return handleError ('处理 Answer' , 'PeerConnection 不存在' ); } if (peer.value .signalingState !== 'have-local-offer' ) { return console .warn (`收到 Answer 但信令状态为 ${peer.value.signalingState} ,忽略。` ); } try { console .log ('设置远端 Answer 描述...' ); await peer.value .setRemoteDescription (new RTCSessionDescription ({ type : 'answer' , sdp : answerData.sdp })); console .log ("远端 Answer 已设置,P2P 连接即将通过 ICE 建立..." ); } catch (error) { handleError ('设置远端 Answer' , error); } }; const callRemote = async ( ) => { console .log ('发起视频请求...' ); if (!socket.value ?.connected ) { return setStatus ('错误' , "未连接到信令服务器" ); } if (communicating.value || calling.value || caller.value || called.value ) { return console .warn ("已在通话或呼叫中" ); } const stream = await getLocalStream (); if (!stream) return ; caller.value = true ; calling.value = true ; setStatus ('呼叫中...' ); sendSignalingMessage ('callRemote' ); console .log ("已发送 callRemote 事件,等待对方接受..." ); }; const acceptCall = async ( ) => { console .log ('接受视频邀请...' ); if (!socket.value ?.connected ) { return setStatus ('错误' , "未连接到信令服务器" ); } if (!called.value || !calling.value ) { return console .warn ("状态不符,无法接受" ); } const stream = await getLocalStream (); if (!stream) return ; const pc = createPeerConnectionAndAddTracks (); if (!pc) { sendSignalingMessage ('hangUp' ); hangUpCleanup (false ); return ; } calling.value = false ; setStatus ('正在连接...' ); sendSignalingMessage ('acceptCall' ); console .log ("已发送 acceptCall 事件,准备接收 Offer..." ); }; const cleanupRTC = ( ) => { console .log ("清理 WebRTC 资源..." ); if (statsIntervalId) { clearInterval (statsIntervalId); statsIntervalId = null ; stats.value = null ; } if (peer.value ) { peer.value .onicecandidate = null ; peer.value .ontrack = null ; peer.value .onconnectionstatechange = null ; try { peer.value .getSenders ().forEach (sender => sender.track ?.stop ()); peer.value .getReceivers ().forEach (receiver => receiver.track ?.stop ()); } catch (e) { console .warn ("清理 sender/receiver 时出错:" , e); } peer.value .close (); peer.value = null ; console .log ("PeerConnection 已关闭并清理。" ); } if (localStream.value ) { localStream.value .getTracks ().forEach (track => track.stop ()); localStream.value = undefined ; console .log ("本地媒体流已停止。" ); } if (localVideo.value ) localVideo.value .srcObject = null ; if (remoteVideo.value ) remoteVideo.value .srcObject = null ; console .log ("媒体元素已清理。" ); }; onMounted (() => { const socketInstance = io ('http://localhost:3000' , { transports : ['websocket' ] }); socketInstance.on ("acceptCall" , async (data) => { console .log ("对方已接受呼叫, 来自:" , data?.accepterId ); if (caller.value && !peer.value ) { calling.value = false ; setStatus ('对方已接受,正准备连接...' ); await createOfferAndSend (); } else { console .warn (`收到 acceptCall 但状态不符, caller=${caller.value} , peer=${peer.value} ` ); } }); socketInstance.on ("offer" , async (data : { senderId : string , sdp : string , offerType : RTCSdpType }) => { console .log ("收到 Offer, 来自:" , data?.senderId ); if (called.value && peer.value && !communicating.value ) { await handleOfferAndCreateAnswer (data); } else { console .warn (`收到 Offer 但状态不符` ); } }); socketInstance.on ("answer" , async (data : { senderId : string , sdp : string , answerType : RTCSdpType }) => { console .log ("收到 Answer, 来自:" , data?.senderId ); if (caller.value && peer.value ) { await handleAnswer (data); } else { console .warn (`收到 Answer 但状态不符` ); } }); socketInstance.on ("candidate" , async (data) => { console .log ("步骤5将处理: 收到 ICE Candidate..." ); }); socket.value = socketInstance; }); </script>
测试:
重启后端 (python server_flask.py
)。 用两个浏览器窗口打开前端。 窗口 A 点击“发起视频”,同意权限。本地预览出现,状态“呼叫中…”。窗口 B 收到邀请,点击“接受”,同意权限。本地预览出现,状态“正在连接…”。观察控制台日志:双方成功获取媒体流、创建 PeerConnection、添加轨道的日志。 窗口 A: 在收到 acceptCall
后,应看到创建 Offer、设置本地 Offer、发送 Offer 的日志。窗口 B: 应看到收到 Offer、设置远端 Offer、创建 Answer、设置本地 Answer、发送 Answer 的日志。窗口 A: 应看到收到 Answer、设置远端 Answer 的日志。 此时,SDP 协商完成!但视频仍不会通,因为网络路径 (ICE Candidate) 还未交换。状态可能停留在 “已连接”。 5.实现 ICE Candidate 交换与显示远端流 目标:
在 RTCPeerConnection
生成 ICE Candidate 时,通过信令服务器发送给对方。 接收并添加对方发送的 ICE Candidate 到本地 RTCPeerConnection
。 在 ontrack
事件触发时,获取远端媒体流并显示在 remoteVideo
元素中,同时更新通话状态。 (可选) 实现并启动 WebRTC 统计信息获取。 1. 后端修改 (server_flask.py
)
在 server_flask.py
的 “Socket.IO 事件处理” 部分,添加 用于转发 candidate
消息的事件处理器:
Python
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 ## server_flask.py (在 handle_answer 和 handle_disconnect 之间或之后添加) ## --- 新增:处理 WebRTC ICE Candidate 转发 --- @socketio.on('candidate') def handle_candidate(data): """处理收到的 ICE Candidate,并转发给同房间其他人""" sid = request.sid room_id = data.get('roomId') candidate_info = data.get('candidate') # 前端发送时键名为 'candidate' # logger.debug(f"收到来自 sid={sid} 的 Candidate,房间: {room_id}") # Debug 时可取消注释 if room_id and room_id in rooms_data and candidate_info: # 将 Candidate 事件和信息转发给房间内的其他人 (不包括发送者) # 前端应监听 'candidate' 事件 emit('candidate', {'senderId': sid, 'candidate': candidate_info}, room=room_id, skip_sid=sid) # logger.debug(f"已将 Candidate 事件转发给房间 {room_id} (除 {sid} 外)") else: logger.warning(f"无法转发 Candidate:房间 {room_id} 无效、用户不在房间或缺少 Candidate 信息。") ## ------------------------------------ ## 其他事件处理器和主程序入口保持不变
2. 前端修改 (src/App.vue
)
在 <script setup lang="ts">
内部:
完成 setupPeerConnectionEvents
函数中 onicecandidate
和 ontrack
的内部逻辑。新增 handleRemoteCandidate
辅助函数。完成 onMounted
中对 candidate
事件的处理逻辑。实现 startGettingStats
函数。更新 cleanupRTC
函数以清除统计定时器。请在 src/App.vue
的 <script setup>
部分替换或添加/完成以下代码:
TypeScript
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 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 // --- 更新/完成 setupPeerConnectionEvents 函数 --- const setupPeerConnectionEvents = (pcInstance: RTCPeerConnection) => { // 监听本地 ICE Candidate 生成 pcInstance.onicecandidate = (event) => { // --- 完成 ICE Candidate 发送逻辑 --- if (event.candidate) { // 检查 candidate 是否存在 console.log('发送本地 ICE Candidate...'); // 使用辅助函数发送 'candidate' 事件 sendSignalingMessage('candidate', { candidate: event.candidate.toJSON() }); } else { console.log("本地 ICE Candidate 收集完成。"); // event.candidate 为 null 表示收集完毕 } // ---------------------------------- }; // 监听远端媒体轨道添加 pcInstance.ontrack = (event) => { // --- 完成 Track 处理逻辑 --- console.log('收到远端轨道:', event.track.kind, '所属流:', event.streams[0]); // 将收到的第一个流附加到 remoteVideo 元素上播放 if (remoteVideo.value && event.streams && event.streams[0]) { // 避免重复设置同一个流 if (remoteVideo.value.srcObject !== event.streams[0]) { remoteVideo.value.srcObject = event.streams[0]; remoteVideo.value.play().catch(e => console.error("远端视频播放失败:", e)); console.log('已将远端流附加到 video 元素'); // 确认进入通话状态 calling.value = false; // 不再是呼叫中/响铃中 communicating.value = true; // 正式进入通话中 setStatus('通话中'); // 更新状态 startGettingStats(); // 连接成功,开始获取统计信息 } } else { console.warn("无法附加远端流到 video 元素"); } // -------------------------- }; // 监听连接状态变化 (保持不变) pcInstance.onconnectionstatechange = () => { /* ... */ }; }; // --- 新增:处理远端 Candidate 的辅助函数 --- const handleRemoteCandidate = async (candidateData: any) => { // console.log("尝试添加远端 Candidate..."); // 确保 PeerConnection 实例存在,并且收到的 candidate 有效,且连接未关闭 if (peer.value && candidateData && peer.value.signalingState !== 'closed') { try { // 使用收到的信息创建 RTCIceCandidate 对象并添加到 PeerConnection await peer.value.addIceCandidate(new RTCIceCandidate(candidateData)); // console.log("已添加远端 ICE Candidate"); } catch (e) { // 忽略添加候选者时可能出现的常见错误(例如重复添加、状态不对等) if (e instanceof DOMException && e.name === 'OperationError') { console.warn(`添加 ICE Candidate 时忽略错误: ${e.message}`); } else { console.error("添加远端 ICE Candidate 失败:", e); } } } else if (!candidateData) { console.log("收到 null ICE Candidate (对方收集完成)"); } else { console.warn(`收到 Candidate 但 PC 状态异常: peer=${peer.value}, state=${peer.value?.signalingState}`); } }; // --- 在 onMounted 的 socketInstance 事件监听中完成 --- onMounted(() => { const socketInstance = io('http://localhost:3000', { transports: ['websocket'] }); // ... (connect, connectionSuccess, error, disconnect, room, call, accept, hangup, offer, answer 监听器保持不变) ... // --- 完成 'candidate' 监听器逻辑 --- socketInstance.on("candidate", async (data) => { // 标记为 async // console.log("收到 ICE Candidate, 来自:", data?.senderId); await handleRemoteCandidate(data.candidate); // 调用处理函数 }); // ------------------------------------ socket.value = socketInstance; // 保存实例 }); // --- 实现 WebRTC 统计获取函数 --- const startGettingStats = () => { if (statsIntervalId) clearInterval(statsIntervalId); // 清除旧的定时器 console.log("开始获取 WebRTC 统计信息..."); statsIntervalId = setInterval(async () => { // 确保 PeerConnection 存在且处于连接状态 if (peer.value && peer.value.connectionState === 'connected') { try { const report = await peer.value.getStats(null); // 获取所有统计信息 let interestingStats: any = {}; // 存储我们关心的统计数据 let remoteCandidateInfo: any = {}; // 临时存储远端候选信息 report.forEach(item => { // 筛选常用的统计信息 if (item.type === 'inbound-rtp' && item.kind === 'video') { interestingStats.inboundVideo = { 收到的包数: item.packetsReceived, 抖动: item.jitter?.toFixed(4), 丢失的包数: item.packetsLost, NACK数: item.nackCount, PLI数: item.pliCount }; } else if (item.type === 'candidate-pair' && item.state === 'succeeded') { interestingStats.candidatePair = { 本地候选ID: item.localCandidateId, 远端候选ID: item.remoteCandidateId, RTT_秒: item.currentRoundTripTime?.toFixed(4) }; } else if (item.type === 'remote-candidate') { remoteCandidateInfo[item.id] = { 地址端口: `${item.address}:${item.port}`, 类型: item.candidateType }; } }); if (interestingStats.candidatePair && remoteCandidateInfo[interestingStats.candidatePair.远端候选ID]) { interestingStats.成功远端候选 = remoteCandidateInfo[interestingStats.candidatePair.远端候选ID]; } stats.value = interestingStats; // 更新响应式 ref } catch (err) { console.error("获取统计信息失败:", err); if (statsIntervalId) clearInterval(statsIntervalId); statsIntervalId = null; } } else { // 如果连接断开或 PC 不存在,停止获取 if (statsIntervalId) { console.log("连接非 'connected' 状态,停止获取统计信息。"); clearInterval(statsIntervalId); statsIntervalId = null; stats.value = null; } } }, 2000); // 每 2 秒获取一次 }; // --- 更新 cleanupRTC 函数以清除定时器 --- const cleanupRTC = () => { console.log("清理 WebRTC 资源..."); // --- 确保清除定时器 --- if (statsIntervalId) { clearInterval(statsIntervalId); statsIntervalId = null; stats.value = null; // 清空统计显示 console.log("WebRTC 统计定时器已清除。"); } // --------------------- if (peer.value) { /* ... 关闭 PC 和轨道 ... */ } if (localStream.value) { /* ... 停止本地流 ... */ } if (localVideo.value) localVideo.value.srcObject = null; if (remoteVideo.value) remoteVideo.value.srcObject = null; console.log("媒体元素已清理。"); }; // --- 其他函数和生命周期钩子保持不变 --- </script>
测试 (最终完整流程):
重启后端 (python server_flask.py
)。 用两个浏览器窗口打开前端页面。 窗口 A 点击“发起视频”,同意权限。 窗口 B 收到邀请,点击“接受”,同意权限。 观察:双方的本地视频预览出现。 状态经历“呼叫中…”、“收到呼叫邀请”、“对方已接受…”、“正在连接…”等变化。 控制台会打印 Offer, Answer, 以及大量的 Candidate 交换日志。 关键: 等待几秒钟,双方的 remoteVideo
(右下角小窗口) 应该会成功显示 对方 的摄像头画面!状态最终变为“通话中”。 下方的统计信息区域开始显示数据(如果启用了)。 任一窗口点击“挂断”,双方视频通话结束,状态恢复。 (可选) 调试: 如果视频没有出现,检查浏览器控制台是否有 WebRTC 错误。在 Chrome/Edge 中打开 chrome://webrtc-internals
,查找当前连接,检查 ICE 候选对 (Candidate Pair) 是否有成功的 (state = succeeded),以及音视频流的收发包情况。最终完整后端代码 (server_flask.py
) 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 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 from flask import Flask, request from flask_socketio import SocketIO, emit, join_room, leave_room import loggingimport os import weakref logging.basicConfig(level=logging.INFO, format ='%(asctime)s %(levelname)s:%(name)s: %(message)s' ) logger = logging.getLogger("flask_socketio_server" ) app = Flask(__name__) app.config['SECRET_KEY' ] = os.urandom(24 ) socketio = SocketIO(app, cors_allowed_origins="*" , async_mode='eventlet' ) logger.info("Flask-SocketIO 服务器准备就绪..." ) rooms_data: dict [str , set [str ]] = {} sid_room: dict [str , str ] = {} @socketio.on('connect' ) def handle_connect (): sid = request.sid logger.info(f"客户端 sid = {sid} 已连接" ) try : emit("connectionSuccess" , room=sid) except Exception as e: logger.error(f"向 sid={sid} 发送 connectionSuccess 时出错: {e} " ) @socketio.on('disconnect' ) def handle_disconnect (): sid = request.sid logger.info(f"客户端已断开连接: sid={sid} " ) if sid in sid_room: room_id = sid_room[sid] logger.info(f"客户端 sid={sid} 正在从房间 {room_id} 断开..." ) try : leave_room(room_id, sid=sid) if room_id in rooms_data: rooms_data[room_id].discard(sid) logger.info(f"sid={sid} 已从 rooms_data[{room_id} ] 移除" ) if not rooms_data[room_id]: del rooms_data[room_id] logger.info(f"房间已移除 (空): {room_id} " ) else : logger.info(f"向房间 {room_id} 广播 peerLeft 事件 (来自 {sid} )" ) socketio.emit('peerLeft' , {'peerId' : sid}, room=room_id) except Exception as e: logger.error(f"处理 sid={sid} 离开房间 {room_id} 时出错: {e} " ) finally : del sid_room[sid] else : logger.warning(f"客户端 sid={sid} 未在任何房间中" ) logger.info(f"客户端 sid={sid} 断开连接处理完成" ) @socketio.on('joinRoom' ) def handle_join_room (data ): """处理客户端加入房间的请求""" sid = request.sid room_id = data.get("roomId" ) if not room_id: logger.warning(f"sid={sid} 请求加入房间,但未提供 roomId" ) return if sid in sid_room: old_room_id = sid_room[sid] if old_room_id in rooms_data and sid in rooms_data[old_room_id]: try : leave_room(old_room_id, sid=sid) rooms_data[old_room_id].discard(sid) logger.info(f"sid={sid} 已离开旧房间: {old_room_id} " ) if not rooms_data[old_room_id]: del rooms_data[old_room_id] logger.info(f"房间已移除 (空): {old_room_id} " ) socketio.emit("peerLeft" , {"peerId" : sid}, room=old_room_id) except Exception as e: logger.error(f"处理 sid={sid} 离开旧房间 {old_room_id} 时出错: {e} " ) del sid_room[sid] try : join_room(room_id,sid=sid) if room_id not in rooms_data: rooms_data[room_id] = set () rooms_data[room_id].add(sid) sid_room[sid] = room_id logger.info(f"sid={sid} 已加入房间: {room_id} " ) socketio.emit('peerJoined' , {'peerId' : sid}, room=room_id, skip_sid=sid) except Exception as e: logger.error(f"处理 sid={sid} 加入房间 {room_id} 时出错: {e} " ) @socketio.on('callRemote' ) def handle_call_remote (data ): """处理发起呼叫请求,并转发给同房间其他人""" sid = request.sid room_id = data.get("roomId" ) logger.info(f"收到来自 sid={sid} 的 callRemote 请求,房间: {room_id} " ) if room_id in rooms_data: emit('callRemote' , {'callerId' : sid}, room=room_id, skip_sid=sid) logger.info(f"已将 callRemote 事件转发给房间 {room_id} (除 {sid} 外)" ) else : logger.warning(f"无法转发 callRemote:房间 {room_id} 无效或不存在。" ) @socketio.on('acceptCall' ) def handle_accept_call (data ): """处理接受呼叫请求,并转发给发起方""" sid = request.sid room_id = data.get('roomId' ) logger.info(f"收到来自 sid={sid} 的 acceptCall 请求,房间: {room_id} " ) if room_id and room_id in rooms_data: emit('acceptCall' , {'accepterId' : sid}, room=room_id, skip_sid=sid) logger.info(f"已将 acceptCall 事件转发给房间 {room_id} (除 {sid} 外)" ) else : logger.warning(f"无法转发 acceptCall:房间 {room_id} 无效或不存在。" ) @socketio.on('hangUp' ) def handle_hang_up (data ): """处理挂断请求,并转发给同房间其他人""" sid = request.sid room_id = data.get('roomId' ) logger.info(f"收到来自 sid={sid} 的 hangUp 请求,房间: {room_id} " ) if room_id and room_id in rooms_data: emit('hangUp' , {'peerId' : sid}, room=room_id, skip_sid=sid) logger.info(f"已将 hangUp 事件转发给房间 {room_id} (除 {sid} 外)" ) else : logger.warning(f"无法转发 hangUp:房间 {room_id} 无效或不存在。" ) @socketio.on("offer" ) def handle_offer (data ): """处理收到的Offer SDP 并转发给房间内其他人""" sid = request.sid room_id = data.get("roomId" ) sdp = data.get("sdp" ) offer_type = data.get("offerType" ) logger.info(f"收到来自 sid={sid} 的 Offer,房间: {room_id} " ) if room_id and room_id in rooms_data and sdp: emit("offer" ,{"senderId" :sid,"sdp" :sdp,"offerType" :offer_type},room=room_id,skip_sid=sid) logger.info(f"已将 Offer 事件转发给房间 {room_id} (除 {sid} 外)" ) else : logger.warning(f"无法转发 Offer:房间 {room_id} 无效、用户不在房间或缺少SDP。" ) @socketio.on('answer' ) def handle_answer (data ): """处理收到的 Answer SDP,并转发给同房间其他人""" sid = request.sid room_id = data.get('roomId' ) sdp = data.get('sdp' ) answer_type = data.get('answerType' ) logger.info(f"收到来自 sid={sid} 的 Answer,房间: {room_id} " ) if room_id and room_id in rooms_data and sdp: emit('answer' , {'senderId' : sid, 'sdp' : sdp, 'answerType' : answer_type}, room=room_id, skip_sid=sid) logger.info(f"已将 Answer 事件转发给房间 {room_id} (除 {sid} 外)" ) else : logger.warning(f"无法转发 Answer:房间 {room_id} 无效、用户不在房间或缺少SDP。" ) @socketio.on('candidate' ) def handle_candidate (data ): """处理收到的 ICE Candidate,并转发给同房间其他人""" sid = request.sid room_id = data.get('roomId' ) candidate_info = data.get('candidate' ) logger.debug(f"收到来自 sid={sid} 的 Candidate,房间: {room_id} " ) if room_id and room_id in rooms_data and candidate_info: emit('candidate' , {'senderId' : sid, 'candidate' : candidate_info}, room=room_id, skip_sid=sid) logger.debug(f"已将 Candidate 事件转发给房间 {room_id} (除 {sid} 外)" ) else : logger.warning(f"无法转发 Candidate:房间 {room_id} 无效、用户不在房间或缺少 Candidate 信息。" ) if __name__ == '__main__' : logger.info("准备启动 Flask-SocketIO 服务器 on port 3000..." ) socketio.run(app, host='127.0.0.1' , port=3000 , debug=True , allow_unsafe_werkzeug=True )
最终完整前端代码 (src/App.vue
) Code snippet
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 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 <script lang="ts" setup> import { ref, onMounted, onUnmounted, computed } from 'vue' import { io, Socket } from 'socket.io-client' // --- 响应式状态 --- const socket = ref<Socket | undefined>() // Socket.IO 实例 const called = ref(false) // 当前用户是否是接收方 (收到呼叫) const caller = ref(false) // 当前用户是否是发起方 const calling = ref(false) // 是否正处于"呼叫中/等待接听"的状态 const communicating = ref(false) // 是否正在视频通话中 const localVideo = ref<HTMLVideoElement | null>(null) // 本地视频 <video> 元素引用 const remoteVideo = ref<HTMLVideoElement | null>(null) // 远端视频 <video> 元素引用 const currentTheme = ref('tropicalLight') // 主题名称 const peer = ref<RTCPeerConnection | null>(null) // RTCPeerConnection 实例 const localStream = ref<MediaStream | undefined>() // 本地媒体流 const errorMessage = ref('') // 用于在界面上显示错误信息 const roomId = ref('room001') // 示例房间ID const status = ref('空闲') // 连接状态显示 const stats = ref<any>(null) // 存储 WebRTC 统计信息 let statsIntervalId: number | null = null // 存储统计定时器的ID const isConnecting = ref(false) // 连接状态 const isConnected = ref(false) // 连接状态 const pcConfig = { iceServers: [ { urls: ['stun:stun.l.google.com:19302', 'stun:stun1.l.google.com:19302', 'stun:stun2.l.google.com:19302', 'stun:stun3.l.google.com:19302', 'stun:stun4.l.google.com:19302'] } ] } // --- 计算属性 (用于样式) --- const statusClass = computed(() => { switch (status.value) { case '已连接': case '通话中': return 'badge-success'; case '连接中': case '连接信令服务器...': case '正在连接...': case '呼叫中...': case '收到呼叫邀请': case '对方已接受,正在建立连接...': return 'badge-info'; case '错误': return 'badge-error'; case '已断开': case '已挂断': case '空闲': default: return 'badge-neutral'; } }); // 更新后的 cleanupRTC 函数 const cleanupRTC = () => { console.log("清理 WebRTC 资源..."); if (statsIntervalId) { clearInterval(statsIntervalId); statsIntervalId = null; stats.value = null; } if (peer.value) { peer.value.onicecandidate = null; peer.value.ontrack = null; peer.value.onconnectionstatechange = null; try { peer.value.getSenders().forEach(sender => sender.track?.stop()); peer.value.getReceivers().forEach(receiver => receiver.track?.stop()); } catch (e) { console.warn("清理 sender/receiver 时出错:", e); } peer.value.close(); peer.value = null; console.log("PeerConnection 已关闭并清理。"); } // 清理本地媒体流 if (localStream.value) { localStream.value.getTracks().forEach(track => track.stop()); localStream.value = undefined; console.log("本地媒体流已停止。"); } if (localVideo.value) localVideo.value.srcObject = null; if (remoteVideo.value) remoteVideo.value.srcObject = null; console.log("媒体元素已清理。"); }; // 重置所有与通话相关的状态变量 const resetStates = () => { console.log("重置状态..."); called.value = false; caller.value = false; calling.value = false; communicating.value = false; // 保留 status 和 errorMessage 的当前值,由调用者决定后续状态 }; // 开始获取WebRTC统计信息 const startGettingStats = () => { if (statsIntervalId) clearInterval(statsIntervalId); statsIntervalId = window.setInterval(() => { if (peer.value) { peer.value.getStats().then(statsReport => { const statsObj: Record<string, any> = {}; statsReport.forEach(report => { if (report.type === 'inbound-rtp' || report.type === 'outbound-rtp') { statsObj[report.id] = report; } }); stats.value = statsObj; }); } }, 2000); // 每2秒更新一次 }; // 停止视频通话流(用于错误处理和主动挂断) const stopStreaming = () => { hangUpCleanup(); }; // --- 更新 setupPeerConnectionEvents 函数 --- const setupPeerConnectionEvents = (pcInstance: RTCPeerConnection) => { // 监听本地 ICE Candidate 生成 pcInstance.onicecandidate = (event) => { // --- 步骤 5 实现 --- if (event.candidate && socket.value && socket.value.connected) { console.log('发送本地 ICE Candidate...'); // 将 candidate 通过 WebSocket 发送给信令服务器 socket.value.emit('candidate', { // 发送 'candidate' 事件 roomId: roomId.value, candidate: event.candidate.toJSON() // 发送 JSON 格式 }); } else if (!event.candidate) { console.log("本地 ICE Candidate 收集完成。"); } // --- 结束步骤 5 实现 --- }; // 监听远端媒体轨道添加 (替代旧的 onaddstream) pcInstance.ontrack = (event) => { // --- 步骤 6 实现 --- console.log('收到远端轨道:', event.track, '所属流:', event.streams[0]); // 将收到的第一个流附加到 remoteVideo 元素上播放 if (remoteVideo.value && event.streams && event.streams[0]) { // 避免重复设置同一个流 if (remoteVideo.value.srcObject !== event.streams[0]) { remoteVideo.value.srcObject = event.streams[0]; remoteVideo.value.play().catch(e => console.error("远端视频播放失败:", e)); console.log('已将远端流附加到 video 元素'); // 确认进入通话状态 calling.value = false; // 不再是呼叫中/响铃中 communicating.value = true; // 正式进入通话中 status.value = '通话中'; // 更新状态 startGettingStats(); // 开始获取统计信息 } } // --- 结束步骤 6 实现 --- }; // 监听连接状态变化 (保持不变) pcInstance.onconnectionstatechange = () => { if (!peer.value) return; const state = peer.value.connectionState; console.log(`PeerConnection 连接状态改变: ${state}`); switch (state) { case 'connecting': status.value = '连接中'; isConnecting.value = true; isConnected.value = false; break; case 'connected': // 连接成功,但等 ontrack 后再确认 communicating 状态 status.value = '已连接'; isConnecting.value = false; isConnected.value = true; errorMessage.value = ''; break; case 'disconnected': status.value = '已断开'; isConnected.value = false; isConnecting.value = false; console.warn("WebRTC 连接已断开..."); break; case 'failed': status.value = '错误'; errorMessage.value = 'WebRTC 连接失败。'; isConnected.value = false; isConnecting.value = false; stopStreaming(); break; case 'closed': status.value = '已断开'; isConnected.value = false; isConnecting.value = false; console.log("WebRTC 连接已关闭。"); break; } }; }; // 更新后的 callRemote 方法 const callRemote = async () => { console.log('发起视频请求...'); if (!socket.value || !socket.value.connected) { errorMessage.value = "未连接到信令服务器"; status.value = '错误'; return; } if (communicating.value || calling.value || caller.value || called.value) { console.warn("已在通话或呼叫中"); return; } // 1. 获取本地媒体流 (这是新增的关键部分) const stream = await getLocalStream(); if (!stream) { errorMessage.value = "无法获取本地媒体流,无法发起呼叫"; status.value = '错误'; return; // 获取失败则不继续 } // 2. 更新状态并发送呼叫信令 caller.value = true; calling.value = true; status.value = '呼叫中...'; socket.value.emit('callRemote', { roomId: roomId.value }); console.log("已发送 callRemote 事件,等待对方接受..."); // Offer 的创建和发送现在移动到收到 'acceptCall' 事件后 }; // 更新后的 acceptCall 方法 const acceptCall = async () => { console.log('接受视频邀请...'); if (!socket.value || !socket.value.connected) { errorMessage.value = "未连接到信令服务器"; status.value = '错误'; return; } if (!called.value || !calling.value) { console.warn("没有收到呼叫或不在呼叫状态,无法接受"); return; } // 1. 获取本地媒体流 (这是新增的关键部分) const stream = await getLocalStream(); if (!stream) { errorMessage.value = "无法获取本地媒体流,无法接听"; status.value = '错误'; return; // 获取失败则不继续 } // 2. 更新状态 calling.value = false; // 结束响铃状态 status.value = '正在连接...'; // 进入连接协商状态 // 3. 创建 PeerConnection 并添加本地轨道 (这是新增的关键部分) const pc = createPeerConnectionAndAddTracks(); if (!pc) { // 创建失败,错误信息已在辅助函数中设置 return; } // 4. 发送接受信令给服务器 socket.value.emit('acceptCall', { roomId: roomId.value }); console.log("已发送 acceptCall 事件,并准备好接收 Offer..."); // 此时接收方准备好接收 Offer 了 }; // 点击"挂断"按钮或需要主动结束通话时调用 const hangUp = () => { console.log('请求挂断视频...'); // 发送挂断信令给服务器转发 (如果还在线) if (socket.value && socket.value.connected) { socket.value.emit('hangUp', { roomId: roomId.value }); console.log("已发送 hangUp 事件"); } // 执行本地的清理操作 hangUpCleanup(); }; // 本地清理逻辑 (挂断或收到对方挂断时调用) const hangUpCleanup = () => { console.log("执行本地挂断清理..."); cleanupRTC(); // 清理 WebRTC 相关资源 (后续实现) resetStates(); // 重置界面状态变量 // 可以根据情况设置最终状态,比如 '已挂断' 或 '空闲' if (status.value !== '错误') { // 避免覆盖错误状态 status.value = '已挂断'; // 短暂显示"已挂断"后可以变回"空闲"或"已连接信令服务器" setTimeout(() => { if (status.value === '已挂断') { status.value = socket.value?.connected ? '已连接信令服务器' : '空闲'; } }, 2000); } }; // --- WebRTC 核心逻辑 --- // 实现获取本地媒体流 const getLocalStream = async (): Promise<MediaStream | undefined> => { console.log("尝试获取本地媒体流..."); errorMessage.value = ''; // 清除旧错误 try { if (localStream.value) { // 如果已有流,先停止 localStream.value.getTracks().forEach(track => track.stop()); console.log("已停止之前的本地媒体轨道。"); } const stream = await navigator.mediaDevices.getUserMedia({ audio: true, // 开启音频 video: { width: 640, height: 480 } // 请求视频 }); console.log('成功获取本地媒体流:', stream.id); localStream.value = stream; // 保存流引用 if (localVideo.value) { localVideo.value.srcObject = stream; // play() 返回 Promise,最好 catch 一下可能的错误 (例如用户未互动) localVideo.value.play().catch(e => console.error("本地视频自动播放失败:", e)); } return stream; // 返回获取到的流 } catch (error: any) { // 处理获取媒体流失败的情况 console.error('getUserMedia 错误:', error); errorMessage.value = `无法访问媒体设备: ${error.name}`; // 向用户显示错误 status.value = '错误'; return undefined; // 返回 undefined 表示失败 } }; // 实现创建 PeerConnection 和添加轨道的辅助函数 const createPeerConnectionAndAddTracks = (): RTCPeerConnection | null => { console.log("创建 PeerConnection 并添加轨道..."); try { const pcInstance = new RTCPeerConnection(pcConfig); // 创建实例 // 设置事件监听器 setupPeerConnectionEvents(pcInstance); // 将本地流的轨道添加到 PeerConnection if (localStream.value) { localStream.value.getTracks().forEach(track => { try { pcInstance.addTrack(track, localStream.value!); console.log(`已添加本地 ${track.kind} 轨道`); } catch (e) { console.error(`添加本地 ${track.kind} 轨道失败:`, e); } }); } else { console.error("无法添加轨道:本地流无效!"); errorMessage.value = "本地摄像头/麦克风流无效"; status.value = '错误'; return null; // 创建失败 } peer.value = pcInstance; // 保存到 ref return pcInstance; } catch (e) { console.error("创建 PeerConnection 失败:", e); errorMessage.value = "创建 WebRTC 连接失败"; status.value = '错误'; return null; } }; // --- Socket.IO 连接 --- // --- Socket.IO 连接与事件监听 --- onMounted(() => { const socketInstance = io('http://localhost:3000', { transports: ['websocket'] }); socketInstance.on("connectionSuccess", () => { console.log("连接成功"); status.value = '已连接信令服务器'; if (socket.value) { socket.value.emit('joinRoom', { roomId: roomId.value }); console.log(`已发送加入房间请求: ${roomId.value}`); } }); socketInstance.on("connect_error", (err) => { console.log("连接失败", err); status.value = '连接失败'; }); socketInstance.on("disconnect", (reason) => { console.log("断开连接", reason); status.value = '已断开'; }); // --- 新增:监听呼叫相关事件 --- socketInstance.on("callRemote", (data) => { // 收到呼叫请求 console.log("收到呼叫请求, 来自:", data?.callerId); // 检查是否已在通话或呼叫中,避免冲突 if (communicating.value || calling.value || caller.value) { console.warn("正在通话或呼叫中,忽略新的呼叫请求"); // 可以选择给对方发送一个 "busy" 信号 // socket.value?.emit('busy', { roomId: roomId.value, targetSid: data?.callerId }); return; } called.value = true; // 标记为被呼叫方 calling.value = true; // 进入响铃状态 status.value = '收到呼叫邀请'; }); socketInstance.on("acceptCall", async (data) => { // 标记为 async console.log("对方已接受呼叫, 来自:", data?.accepterId); if (caller.value) { // 只有发起方处理 calling.value = false; status.value = '对方已接受,正在建立连接...'; // --- 触发 Offer 流程 --- // 确保 PeerConnection 已创建并添加了轨道 // 注意:在 callRemote 时我们已经获取了流,但 PC 在这里创建 const pc = createPeerConnectionAndAddTracks(); if (pc) { try { console.log('创建 Offer...'); const offer = await pc.createOffer({ offerToReceiveAudio: true, offerToReceiveVideo: true }); console.log('设置本地 Offer 描述...'); await pc.setLocalDescription(offer); console.log('通过信令服务器发送 Offer...'); socket.value?.emit('offer', { // 发送 'offer' 事件 roomId: roomId.value, sdp: pc.localDescription?.sdp, offerType: pc.localDescription?.type // 使用 offerType 区分 }); } catch (error) { console.error('创建或发送 Offer 失败:', error); errorMessage.value = '创建 Offer 失败'; status.value = '错误'; hangUpCleanup(); // 出错时挂断 } } } }); socketInstance.on("hangUp", (data) => { // 收到对方挂断信号 console.log("收到对方挂断通知, 来自:", data?.peerId); hangUpCleanup(); // 执行本地清理 status.value = '对方已挂断'; alert("对方已挂断"); // 可选的用户提示 }); // --- 新增 'offer' 监听器 --- socketInstance.on("offer", async (data) => { // 标记为 async console.log("收到 Offer, 来自:", data?.senderId); // 确保是被叫方,且 PC 实例已在 acceptCall 中创建 if (called.value && peer.value && !communicating.value) { try { console.log('设置远端 Offer 描述...'); await peer.value.setRemoteDescription(new RTCSessionDescription({ type: 'offer', sdp: data.sdp })); console.log('创建 Answer...'); const answer = await peer.value.createAnswer(); console.log('设置本地 Answer 描述...'); await peer.value.setLocalDescription(answer); console.log('通过信令服务器发送 Answer...'); socket.value?.emit('answer', { // 发送 'answer' 事件 roomId: roomId.value, sdp: peer.value.localDescription?.sdp, answerType: peer.value.localDescription?.type // 使用 answerType 区分 }); } catch (error) { console.error('处理 Offer 或创建/发送 Answer 失败:', error); errorMessage.value = '创建 Answer 失败'; status.value = '错误'; hangUpCleanup(); } } else { console.warn("收到 Offer 但状态不符 (非接收方、无 PC 或已通话)"); } }); // --- 新增 'answer' 监听器 --- socketInstance.on("answer", async (data) => { // 标记为 async console.log("收到 Answer, 来自:", data?.senderId); // 确保是发起方且 PC 实例存在 if (caller.value && peer.value) { if (peer.value.signalingState === 'have-local-offer') { // 检查状态是否适合设置 Answer try { console.log('设置远端 Answer 描述...'); await peer.value.setRemoteDescription(new RTCSessionDescription({ type: 'answer', sdp: data.sdp })); console.log("远端 Answer 已设置,P2P 连接即将建立..."); } catch (error) { console.error('设置远端 Answer 失败:', error); errorMessage.value = '设置 Answer 失败'; status.value = '错误'; hangUpCleanup(); } } else { console.warn(`收到 Answer 但当前信令状态为 ${peer.value.signalingState},忽略。`); } } }); // --- 更新 'candidate' 事件处理 --- socketInstance.on("candidate", async (data) => { // 标记为 async // 确保 PeerConnection 实例存在,并且收到的 candidate 有效 if (peer.value && data.candidate && peer.value.signalingState !== 'closed') { try { // 使用收到的信息创建 RTCIceCandidate 对象并添加到 PeerConnection await peer.value.addIceCandidate(new RTCIceCandidate(data.candidate)); console.log("已添加远端 ICE Candidate"); } catch (e) { // 忽略添加候选者时可能出现的常见错误(例如重复添加、状态不对等) if (e instanceof DOMException && e.name === 'OperationError') { console.warn(`添加 ICE Candidate 时忽略错误: ${e.message}`); } else { console.error("添加远端 ICE Candidate 失败:", e); } } } else if (!data.candidate) { console.log("收到 null ICE Candidate (对方收集完成)"); } else { console.warn("收到 Candidate 但 PC 不存在或已关闭"); } }); socketInstance.on("peerJoined", (data) => { console.log(`通知: ${data.peerId} 加入房间`); }); socketInstance.on("peerLeft", (data) => { console.log(`通知: ${data.peerId} 离开房间`); }); // ----------------------------- socket.value = socketInstance; }); // 组件卸载时清理资源 onUnmounted(() => { if (socket.value) { socket.value.disconnect(); } cleanupRTC(); // 确保 WebRTC 也被清理 }); </script> <template> <div class="min-h-screen bg-base-100 transition-colors duration-300 font-sans" :data-theme="currentTheme"> <div class="flex flex-col items-center justify-between p-4 md:p-8 min-h-screen"> <div class="card w-full max-w-3xl bg-base-200 shadow-xl mb-4"> <div class="card-body items-center text-center"> <p class="text-sm md:text-base">使用WebRTC技术,实现高质量的视频通话</p> <p class="mt-2">状态: <span :class="statusClass" class="font-semibold badge badge-md md:badge-lg text-white">{{ status }}</span></p> <p v-if="errorMessage" class="text-error text-sm mt-1">{{ errorMessage }}</p> <p class="text-sm mt-1 text-gray-500">房间号: {{ roomId }}</p> </div> </div> <div class="relative w-full max-w-3xl aspect-video bg-black rounded-lg overflow-hidden shadow-lg"> <video ref="localVideo" autoplay playsinline muted class="absolute inset-0 w-full h-full object-cover z-0 aspect-ratio"></video> <video ref="remoteVideo" autoplay playsinline class="w-1/4 max-w-[160px] h-auto absolute bottom-4 right-4 object-cover border-2 border-base-300 rounded shadow-md z-10 transition-opacity duration-300 remote-video" :class="{ 'opacity-0 pointer-events-none': !communicating }"></video> <dialog id="waiting_modal" class="modal" :open="caller && calling"> <div class="modal-box"> <h3 class="text-lg font-bold">等待对方接听...</h3> <p class="py-4">请耐心等待对方接受您的视频邀请</p> <div class="modal-action"> <button class="btn btn-primary" @click="hangUp">取消</button> </div> </div> <form method="dialog" class="modal-backdrop"> <button @click="hangUp">关闭</button> </form> </dialog> <div v-if="called && calling" class="absolute inset-0 flex flex-col items-center justify-center bg-black bg-opacity-70 text-white z-20"> <p class="mb-4 text-lg">收到视频邀请...</p> <div class="flex gap-4"> <button @click="hangUp" class="btn btn-circle btn-error btn-lg" aria-label="拒绝"> ✗ </button> <button @click="acceptCall" class="btn btn-circle btn-success btn-lg" aria-label="接受"> ✓ </button> </div> </div> </div> <div class="flex gap-4 mt-4"> <button class="btn btn-primary text-white" @click="callRemote" :disabled="communicating || calling || caller || called"> 发起视频 </button> <button class="btn btn-error btn-outline" @click="hangUp"> 挂断 </button> </div> <div v-if="stats" class="mt-4 p-2 bg-base-300 rounded shadow w-full max-w-3xl"> <h3 class="text-sm font-semibold mb-1">WebRTC 统计 (示例)</h3> <pre class="text-xs overflow-auto max-h-24 bg-base-100 p-1 rounded">{{ JSON.stringify(stats, null, 2) }}</pre> </div> </div> </div> </template> <style> .content-container { display: flex; flex-direction: column; align-items: center; justify-content: space-between; /* 让按钮区域和状态信息在底部 */ padding: 1rem; /* 调整内边距 */ min-height: 100vh; } .video-container { position: relative; /* 确保子元素绝对定位是相对于此容器 */ width: 100%; /* 占据可用宽度 */ /* max-width: 800px; */ /* 可以限制最大宽度 */ /* aspect-ratio: 16 / 9; */ /* 保持16:9比例 */ background-color: #000; /* 背景设为黑色 */ /* 移除固定的 height: 100% */ } .main-video { /* 用于本地视频,充满容器 */ display: block; /* 避免 video 底部空隙 */ width: 100%; height: 100%; object-fit: cover; /* 覆盖整个区域,可能裁剪 */ } .remote-video { /* 用于远端视频,小窗效果 */ /* 样式已在 class 属性中定义 */ background-color: #222; /* 给小窗加个背景色 */ } </style>
运行与测试:
启动后端: python server_flask.py
启动前端: npm run dev
测试: 使用两个浏览器窗口或设备进行完整的呼叫、接听、显示视频、查看统计、挂断流程。使用浏览器开发者工具和后端的日志进行调试。这份代码整合了所有步骤,并进行了一些优化和错误处理的改进,形成了一个功能更完整、代码结构更清晰的版本。
5.进阶实现+调优 实战 : 通过 aiortc 做一个超低延迟摄像头转播 前端代码(采用 VUE3+daisyui+tailwindcss 实现) 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 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 <!DOCTYPE html > <html lang ="zh-CN" data-theme ="dark" > <head > <meta charset ="UTF-8" > <meta name ="viewport" content ="width=device-width, initial-scale=1.0" > <title > Python WebRTC 摄像头实时流 (优化)</title > <script src ="https://cdn.tailwindcss.com?plugins=forms,typography,aspect-ratio,line-clamp" > </script > <link href ="https://cdn.jsdelivr.net/npm/daisyui@4.10.2/dist/full.min.css" rel ="stylesheet" type ="text/css" /> <style > body { font-family : sans-serif; } #app { max-width : 800px ; margin : 2rem auto; padding : 1rem ; } video { background-color : #000 ; } </style > </head > <body class ="bg-base-200" > <div id ="app" class ="p-4" > <h1 class ="text-3xl font-bold mb-4 text-center text-primary" > Python WebRTC 摄像头</h1 > <div class ="card bg-base-100 shadow-xl mb-6" > <figure class ="bg-black" > <video ref ="videoElement" autoplay playsinline muted class ="w-full h-auto aspect-video" > </video > </figure > <div class ="card-body items-center text-center" > <p class ="mb-2" > 状态: <span :class ="statusClass" class ="font-semibold badge badge-lg" > {{ status }}</span > </p > <div class ="card-actions" > <button @click ="startStreaming" :disabled ="isConnected || isConnecting" class ="btn btn-primary" > 开始推流 </button > <button @click ="stopStreaming" :disabled ="!isConnected && !isConnecting" class ="btn btn-secondary" > 停止推流 </button > </div > <p v-if ="errorMessage" class ="text-error mt-2" > {{ errorMessage }}</p > </div > </div > <div class ="text-center text-xs text-base-content/70" > 建议使用 Chrome/Edge 访问 <code class ="bg-base-300 px-1 rounded" > chrome://webrtc-internals</code > 或 Firefox 访问 <code class ="bg-base-300 px-1 rounded" > about:webrtc</code > 查看连接详情。 </div > <div v-if ="stats" class ="mt-4 p-4 bg-base-100 rounded shadow" > <h3 class ="text-lg font-semibold mb-2" > WebRTC 统计 (示例)</h3 > <pre class ="text-xs overflow-auto max-h-48 bg-base-200 p-2 rounded" > {{ JSON.stringify(stats, null, 2) }}</pre > </div > </div > <script src ="https://unpkg.com/vue@3/dist/vue.global.js" > </script > <script > const { createApp, ref, computed, onMounted, onUnmounted } = Vue ; const app = createApp ({ setup ( ) { const status = ref ('空闲' ); const isConnecting = ref (false ); const isConnected = ref (false ); const errorMessage = ref ('' ); const videoElement = ref (null ); const ws = ref (null ); const pc = ref (null ); const stats = ref (null ); let statsIntervalId = null ; const statusClass = computed (() => { switch (status.value ) { case '已连接' : return 'badge-success' ; case '连接中' : return 'badge-info' ; case '错误' : return 'badge-error' ; case '已断开' : case '空闲' : default : return 'badge-neutral' ; } }); const pcConfig = { iceServers : [ { urls : 'stun:stun.l.google.com:19302' }, { urls : 'stun:stun1.l.google.com:19302' } ] }; const cleanupRTC = ( ) => { console .log ("清理 WebRTC 资源..." ); if (statsIntervalId) { clearInterval (statsIntervalId); statsIntervalId = null ; stats.value = null ; console .log ("WebRTC 统计定时器已清除。" ); } if (pc.value ) { pc.value .onicecandidate = null ; pc.value .ontrack = null ; pc.value .onconnectionstatechange = null ; pc.value .getSenders ().forEach (sender => { if (sender.track ) { sender.track .stop (); } }); pc.value .getReceivers ().forEach (receiver => { if (receiver.track ) { receiver.track .stop (); } }); pc.value .close (); pc.value = null ; console .log ("PeerConnection 已关闭并清理。" ); } if (videoElement.value && videoElement.value .srcObject ) { videoElement.value .srcObject .getTracks ().forEach (track => track.stop ()); videoElement.value .srcObject = null ; console .log ("Video 元素 srcObject 已清理。" ); } }; const cleanupWebSocket = ( ) => { if (ws.value ) { console .log ("清理 WebSocket 资源..." ); ws.value .onopen = ws.value .onmessage = ws.value .onclose = ws.value .onerror = null ; if (ws.value .readyState === WebSocket .OPEN || ws.value .readyState === WebSocket .CONNECTING ) { ws.value .close (); console .log ("WebSocket 已关闭。" ); } else { console .log (`WebSocket 状态为 ${ws.value.readyState} ,无需关闭。` ); } ws.value = null ; } }; const connectWebSocket = ( ) => { return new Promise ((resolve, reject ) => { const wsProtocol = window .location .protocol === 'https:' ? 'wss://' : 'ws://' ; const wsHost = window .location .hostname === 'localhost' ? '127.0.0.1' : window .location .hostname ; const wsUrl = `${wsProtocol} ${wsHost} :8080/ws` ; console .log (`尝试连接 WebSocket 到: ${wsUrl} ` ); status.value = '连接中' ; isConnecting.value = true ; errorMessage.value = '' ; cleanupWebSocket (); ws.value = new WebSocket (wsUrl); ws.value .onopen = () => { console .log ("WebSocket 连接已建立" ); resolve (); }; ws.value .onmessage = (event ) => { try { handleSignalingMessage (JSON .parse (event.data )); } catch (e) { console .error ("解析收到的消息失败:" , event.data , e); errorMessage.value = "收到无效的服务器消息。" ; } }; ws.value .onclose = (event ) => { console .warn (`WebSocket 已关闭: 代码=${event.code} , 原因=${event.reason || '(无原因)' } , 是否正常关闭=${event.wasClean} ` ); if (status.value !== '错误' && status.value !== '空闲' ) { status.value = '已断开' ; } isConnected.value = false ; isConnecting.value = false ; cleanupRTC (); ws.value = null ; }; ws.value .onerror = (error ) => { console .error ("WebSocket 错误:" , error); errorMessage.value = 'WebSocket 连接错误。请检查服务器是否运行,以及网络连接。' ; status.value = '错误' ; isConnected.value = false ; isConnecting.value = false ; reject (new Error ('WebSocket connection failed' )); cleanupRTC (); ws.value = null ; }; }); }; const startStreaming = async ( ) => { if (isConnected.value || isConnecting.value ) { console .warn ("已在连接中或已连接。" ); return ; } console .log ("开始推流处理 (优化版)..." ); try { await connectWebSocket (); console .log ("设置 PeerConnection (优化版)..." ); cleanupRTC (); pc.value = new RTCPeerConnection (pcConfig); pc.value .onicecandidate = (event ) => { if (event.candidate && ws.value && ws.value .readyState === WebSocket .OPEN ) { ws.value .send (JSON .stringify ({ type : "ice" , candidate : event.candidate .toJSON () })); } else if (!event.candidate ) { console .log ("ICE 收集完成。" ); } else { console .warn ("WebSocket 未打开或不存在,无法发送 ICE 候选者。" ); } }; pc.value .ontrack = (event ) => { console .log ("收到远端轨道:" , event.track ); if (event.streams && event.streams [0 ] && videoElement.value ) { if (videoElement.value .srcObject !== event.streams [0 ]) { videoElement.value .srcObject = event.streams [0 ]; console .log ("已将流分配给 video 元素" ); startGettingStats (); } } else { console .warn ("无法将流分配给 video 元素 (元素不存在或流无效)。" ); } }; pc.value .onconnectionstatechange = () => { if (!pc.value ) { console .log ("onconnectionstatechange 触发时 pc 已清理,忽略。" ); return ; } const state = pc.value .connectionState ; console .log (`PeerConnection 状态改变: ${state} ` ); switch (state) { case "connecting" : status.value = "连接中" ; isConnecting.value = true ; isConnected.value = false ; break ; case "connected" : status.value = "已连接" ; isConnecting.value = false ; isConnected.value = true ; errorMessage.value = '' ; break ; case "disconnected" : status.value = '已断开' ; isConnected.value = false ; isConnecting.value = false ; console .warn ("WebRTC 连接已断开,可能尝试自动重连..." ); break ; case "failed" : status.value = '错误' ; errorMessage.value = 'WebRTC 连接失败。请检查网络或 STUN/TURN 服务器配置。' ; isConnected.value = false ; isConnecting.value = false ; stopStreaming (); break ; case "closed" : status.value = '已断开' ; isConnected.value = false ; isConnecting.value = false ; console .log ("WebRTC 连接已关闭。" ); break ; } }; console .log ("创建 Offer (优化版)..." ); const offer = await pc.value .createOffer ({ offerToReceiveAudio : false , offerToReceiveVideo : true }); await pc.value .setLocalDescription (offer); console .log ("本地描述 (Offer) 已设置,发送给服务器..." ); if (ws.value && ws.value .readyState === WebSocket .OPEN ) { ws.value .send (JSON .stringify ({ type : "offer" , sdp : pc.value .localDescription .sdp , type : pc.value .localDescription .type })); } else { throw new Error ("WebSocket 未连接,无法发送 Offer。" ); } } catch (error) { console .error ("启动推流失败:" , error); errorMessage.value = `启动失败: ${error.message || error} ` ; status.value = '错误' ; isConnected.value = false ; isConnecting.value = false ; stopStreaming (); } }; const stopStreaming = ( ) => { console .log ("停止推流..." ); cleanupWebSocket (); cleanupRTC (); if (status.value !== '错误' ) { status.value = '空闲' ; } isConnected.value = false ; isConnecting.value = false ; }; const handleSignalingMessage = async (data ) => { if (!pc.value && (data.type === 'answer' || data.type === 'ice' )) { console .warn ("收到信令消息,但 PeerConnection 不存在或已清理:" , data.type ); return ; } console .log ("处理信令消息:" , data.type ); try { if (data.type === "answer" ) { if (pc.value .signalingState !== 'have-local-offer' ) { console .warn (`收到 Answer 但当前信令状态为 ${pc.value.signalingState} ,忽略。` ); return ; } const answer = new RTCSessionDescription ({ sdp : data.sdp , type : data.type }); await pc.value .setRemoteDescription (answer); console .log ("远端描述 (Answer) 已设置。" ); } else if (data.type === "ice" ) { if (data.candidate ) { const candidate = new RTCIceCandidate (data.candidate ); if (pc.value .signalingState !== 'closed' ) { await pc.value .addIceCandidate (candidate); } else { console .warn ("PeerConnection 已关闭,忽略收到的 ICE 候选者。" ); } } else { console .log ("收到来自服务器的 null ICE 候选者(收集结束)。" ); } } else if (data.type === "error" ) { console .error ("收到来自服务器的错误消息:" , data.message ); errorMessage.value = `服务器错误: ${data.message || '未知错误' } ` ; status.value = '错误' ; stopStreaming (); } } catch (error) { console .error ("处理信令消息时出错:" , error, "消息类型:" , data.type ); errorMessage.value = `处理信令失败 (${data.type} ): ${error.message || error} ` ; status.value = '错误' ; stopStreaming (); } }; const startGettingStats = ( ) => { if (statsIntervalId) clearInterval (statsIntervalId); statsIntervalId = setInterval (async () => { if (pc.value && pc.value .connectionState === 'connected' ) { try { const report = await pc.value .getStats (null ); let interestingStats = {}; report.forEach (item => { if (item.type === 'inbound-rtp' && item.kind === 'video' ) { interestingStats.inboundVideo = { 收到的包数: item.packetsReceived , 收到的字节数: item.bytesReceived , 解码的帧数: item.framesDecoded , 抖动: item.jitter ?.toFixed (4 ), 丢失的包数: item.packetsLost , 时间戳: new Date (item.timestamp ).toLocaleTimeString (), NACK 请求数: item.nackCount , PLI 请求数: item.pliCount }; } else if (item.type === 'candidate-pair' && item.state === 'succeeded' ) { interestingStats.candidatePair = { 本地候选ID : item.localCandidateId .substring (0 , 10 ) + '...' , 远端候选ID : item.remoteCandidateId .substring (0 , 10 ) + '...' , 当前往返时延RTT : item.currentRoundTripTime ?.toFixed (4 ), 可用出向比特率: item.availableOutgoingBitrate ? (item.availableOutgoingBitrate / 1000 ).toFixed (1 ) + ' kbps' : 'N/A' }; } else if (item.type === 'remote-candidate' && interestingStats.candidatePair && item.id === interestingStats.candidatePair .远端候选ID ) { interestingStats.remoteCandidate = { 地址和端口: `${item.address} :${item.port} ` , 协议: item.protocol , 类型: item.candidateType }; } }); stats.value = interestingStats; } catch (err) { console .error ("获取 WebRTC 统计信息失败:" , err); if (statsIntervalId) clearInterval (statsIntervalId); statsIntervalId = null ; } } else { if (statsIntervalId) { console .log ("连接非 'connected' 状态,停止获取统计信息。" ); clearInterval (statsIntervalId); statsIntervalId = null ; stats.value = null ; } } }, 2000 ); }; onMounted (() => { console .log ("组件已挂载。" ); }); onUnmounted (() => { console .log ("组件即将卸载,清理资源..." ); stopStreaming (); }); return { status, isConnecting, isConnected, errorMessage, videoElement, statusClass, stats, startStreaming, stopStreaming }; } }); app.mount ('#app' ); </script > </body > </html >
后端 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 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 import asyncioimport jsonimport loggingimport osimport sslimport threadingimport timeimport weakref import cv2import numpy as npfrom aiohttp import webfrom aiortc import RTCIceCandidate, RTCPeerConnection, RTCSessionDescription, VideoStreamTrack, RTCConfiguration, RTCIceServerfrom aiortc.contrib.media import MediaRelayfrom av import VideoFramelogging.basicConfig(level=logging.INFO) logger = logging.getLogger("server" ) logger.setLevel(logging.INFO) pc_logger = logging.getLogger("pc" ) pc_logger.setLevel(logging.INFO) cam_logger = logging.getLogger("camera" ) cam_logger.setLevel(logging.INFO) ROOT = os.path.dirname(__file__) pcs = set () web_sockets = weakref.WeakSet() class CameraDevice : _instance = None _lock = threading.Lock() def __new__ (cls, *args, **kwargs ): with cls._lock: if cls._instance is None : cls._instance = super (CameraDevice, cls).__new__(cls) cls._instance._initialized = False return cls._instance def __init__ (self, camera_index=0 ): if hasattr (self , '_initialized' ) and self ._initialized: return with self ._lock: if hasattr (self , '_initialized' ) and self ._initialized: return backend_preference = cv2.CAP_MSMF if backend_preference is not None : self .cap = cv2.VideoCapture(camera_index, backend_preference) cam_logger.info(f"尝试使用 OpenCV 后端: {backend_preference} " ) else : self .cap = cv2.VideoCapture(camera_index) cam_logger.info("使用 OpenCV 默认后端" ) if not self .cap.isOpened(): raise RuntimeError(f"无法打开视频源 {camera_index} " ) target_width = 640 target_height = 480 target_fps = 30 self .cap.set (cv2.CAP_PROP_FRAME_WIDTH, target_width) self .cap.set (cv2.CAP_PROP_FRAME_HEIGHT, target_height) self .cap.set (cv2.CAP_PROP_FPS, target_fps) self .cap.set (cv2.CAP_PROP_BUFFERSIZE, 1 ) w = int (self .cap.get(cv2.CAP_PROP_FRAME_WIDTH)) h = int (self .cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) actual_fps = self .cap.get(cv2.CAP_PROP_FPS) buffer_size = int (self .cap.get(cv2.CAP_PROP_BUFFERSIZE)) cam_logger.info(f"摄像头 {camera_index} 配置请求: {target_width} x{target_height} @{target_fps} fps, Buffer: 1" ) cam_logger.info(f"摄像头 {camera_index} 实际配置: ({w} x{h} @{actual_fps} fps, Buffer: {buffer_size} )" ) self .target_fps = actual_fps if actual_fps > 0 else 30 self ._frame_lock = threading.Lock() self ._frame = None self ._thread = threading.Thread(target=self ._capture_loop, daemon=True ) self ._running = True self ._thread.start() self ._initialized = True def _capture_loop (self ): cam_logger.info("摄像头捕获线程已启动" ) last_log_time = time.monotonic() frames_captured_interval = 0 while self ._running: ret, frame = self .cap.read() if not ret: time.sleep(0.005 ) continue with self ._frame_lock: self ._frame = frame frames_captured_interval += 1 current_time = time.monotonic() duration = current_time - last_log_time if duration >= 5.0 : actual_capture_fps = frames_captured_interval / duration cam_logger.info(f"摄像头实际捕获帧率: {actual_capture_fps:.2 f} FPS" ) frames_captured_interval = 0 last_log_time = current_time time.sleep(0.001 ) cam_logger.info("摄像头捕获线程已停止" ) if self .cap.isOpened(): self .cap.release() def get_latest_frame (self ): with self ._frame_lock: if self ._frame is None : return None return self ._frame.copy() def stop (self ): if hasattr (CameraDevice, '_instance' ) and CameraDevice._instance and CameraDevice._instance._initialized: cam_logger.info("正在停止摄像头设备..." ) CameraDevice._instance._running = False if CameraDevice._instance._thread: CameraDevice._instance._thread.join(timeout=1 ) if CameraDevice._instance.cap and CameraDevice._instance.cap.isOpened(): CameraDevice._instance.cap.release() cam_logger.info("摄像头设备已停止。" ) CameraDevice._instance = None camera_device = None try : camera_device = CameraDevice() except Exception as e: logger.error(f"严重: 启动时初始化摄像头失败: {e} 。服务器可能无法传输视频。" ) camera_device = None class CameraVideoStreamTrack (VideoStreamTrack ): """ 一个从全局 CameraDevice 读取帧的视频流轨道。 """ def __init__ (self, camera: CameraDevice ): super ().__init__() self .camera = camera self ._last_valid_frame = None self ._frame_count_sent = 0 self ._log_interval = 5.0 self ._last_log_time_sent = time.monotonic() async def recv (self ): start_wait = time.monotonic() if self .camera is None : frame = VideoFrame(width=640 , height=480 , format ="bgr24" ) pts = int (time.time() * 90000 ) frame.pts = pts frame.time_base = 90000 await asyncio.sleep(1 /30 ) return frame frame_data = self .camera.get_latest_frame() now = time.monotonic() wait_time = now - start_wait if frame_data is None : if self ._last_valid_frame is not None : frame = self ._last_valid_frame pts, time_base = await self .next_timestamp() frame.pts = pts frame.time_base = time_base await asyncio.sleep(max (0 , (1 /30 ) - wait_time)) return frame else : frame = VideoFrame(width=640 , height=480 , format ="bgr24" ) pts, time_base = await self .next_timestamp() frame.pts = pts frame.time_base = time_base await asyncio.sleep(1 /30 ) return frame else : frame = VideoFrame.from_ndarray(frame_data, format ="bgr24" ) pts, time_base = await self .next_timestamp() frame.pts = pts frame.time_base = time_base self ._last_valid_frame = frame self ._frame_count_sent += 1 current_time = time.monotonic() duration = current_time - self ._last_log_time_sent if duration >= self ._log_interval: actual_send_fps = self ._frame_count_sent / duration pc_logger.info(f"WebRTC 发送帧率: {actual_send_fps:.2 f} FPS" ) self ._frame_count_sent = 0 self ._last_log_time_sent = current_time pass return frame async def serve_html (request ): content = open (os.path.join(ROOT, "index.html" ), "r" , encoding="utf-8" ).read() return web.Response(content_type="text/html" , text=content) async def handle_websocket (request ): ws = web.WebSocketResponse() await ws.prepare(request) logger.info("WebSocket 连接已建立" ) web_sockets.add(ws) rtc_configuration = RTCConfiguration( iceServers=[ RTCIceServer(urls="stun:stun.l.google.com:19302" ), RTCIceServer(urls="stun:stun1.l.google.com:19302" ), ] ) pc = RTCPeerConnection(configuration=rtc_configuration) pc_id = f"PeerConnection({hex (id (pc))[-6 :]} )" pcs.add(pc) logger.info(f"{pc_id} : 已为 WebSocket {hex (id (ws))[-6 :]} 创建 (带 STUN 配置)" ) ws.pc = pc @pc.on("icecandidate" ) async def on_icecandidate (candidate ): if candidate: await ws.send_json({"type" : "ice" , "candidate" : candidate.to_dict()}) else : logger.info(f"{pc_id} : ICE 收集完成。" ) @pc.on("connectionstatechange" ) async def on_connectionstatechange (): logger.info(f"{pc_id} : 连接状态为 {pc.connectionState} " ) if pc.connectionState in ["failed" , "closed" , "disconnected" ]: if pc in pcs: logger.info(f"{pc_id} : 正在关闭..." ) await pc.close() pcs.discard(pc) logger.info(f"{pc_id} : 已关闭并移除。" ) if not ws.closed: logger.info(f"关闭关联的 WebSocket {hex (id (ws))[-6 :]} " ) await ws.close(code=1000 , message="PeerConnection closed" ) if camera_device: video_track = CameraVideoStreamTrack(camera_device) pc.addTrack(video_track) logger.info(f"{pc_id} : 已添加摄像头视频轨道。" ) else : logger.warning(f"{pc_id} : 无可用摄像头设备,无法添加视频轨道。" ) try : async for msg in ws: if msg.type == web.WSMsgType.TEXT: try : data = json.loads(msg.data) pc_logger.debug(f"{pc_id} : 收到消息类型: {data.get('type' )} " ) if data["type" ] == "offer" : offer = RTCSessionDescription(sdp=data["sdp" ], type =data["type" ]) await pc.setRemoteDescription(offer) pc_logger.info(f"{pc_id} : 远端描述 (offer) 已设置。" ) answer = await pc.createAnswer() await pc.setLocalDescription(answer) pc_logger.info(f"{pc_id} : 本地描述 (answer) 已设置。" ) await ws.send_json({ "type" : "answer" , "sdp" : pc.localDescription.sdp, "type" : pc.localDescription.type , }) pc_logger.info(f"{pc_id} : 已发送 answer 到 WebSocket {hex (id (ws))[-6 :]} " ) elif data["type" ] == "ice" : candidate_info = data.get("candidate" ) if candidate_info: try : candidate = RTCIceCandidate( sdpMid=candidate_info.get("sdpMid" ), sdpMLineIndex=candidate_info.get("sdpMLineIndex" ), candidate=candidate_info.get("candidate" ) ) if candidate.candidate and candidate.sdpMid is not None and candidate.sdpMLineIndex is not None : await pc.addIceCandidate(candidate) else : pc_logger.warning(f"{pc_id} : 收到无效或不完整的 ICE candidate 字典: {candidate_info} " ) except Exception as e: pc_logger.error(f"{pc_id} : 处理 ICE candidate 时出错: {e} - Candidate Info: {candidate_info} " ) else : pc_logger.info(f"{pc_id} : 收到来自客户端的 null ICE 候选者(表示收集结束)。" ) else : logger.warning(f"收到未知消息类型: {data['type' ]} " ) except json.JSONDecodeError: logger.error(f"WebSocket {hex (id (ws))[-6 :]} : 收到无效 JSON: {msg.data} " ) except Exception as e: logger.error(f"处理 {pc_id} 的消息时出错: {e} " , exc_info=True ) if pc in pcs: await pc.close() pcs.discard(pc) if not ws.closed: await ws.close(code=1011 , message=f"处理消息出错: {e} " ) elif msg.type == web.WSMsgType.ERROR: logger.error(f'WebSocket 连接因异常关闭 {ws.exception()} ' ) except asyncio.CancelledError: logger.info(f"WebSocket {hex (id (ws))[-6 :]} 任务被取消。" ) finally : logger.info(f"WebSocket 连接 {hex (id (ws))[-6 :]} 已关闭" ) web_sockets.discard(ws) if hasattr (ws, 'pc' ) and ws.pc in pcs: logger.info(f"正在关闭与已关闭 WebSocket 关联的 PeerConnection {pc_id} 。" ) await ws.pc.close() pcs.discard(ws.pc) return ws async def on_shutdown (app ): logger.info("服务器正在关闭..." ) coros_pc = [pc.close() for pc in pcs] await asyncio.gather(*coros_pc, return_exceptions=True ) pcs.clear() logger.info("所有 PeerConnection 已关闭。" ) coros_ws = [ws.close(code=1001 , message='服务器关闭' ) for ws in web_sockets] await asyncio.gather(*coros_ws, return_exceptions=True ) logger.info("所有 WebSocket 已关闭。" ) if camera_device: camera_device.stop() logger.info("关闭完成。" ) async def main (): app = web.Application() app.on_shutdown.append(on_shutdown) app.router.add_get("/" , serve_html) app.router.add_get("/ws" , handle_websocket) runner = web.AppRunner(app) await runner.setup() site = web.TCPSite(runner, "0.0.0.0" , 8080 ) logger.info("服务器已启动于 http://0.0.0.0:8080" ) await site.start() await asyncio.Event().wait() if __name__ == "__main__" : try : asyncio.run(main()) except KeyboardInterrupt: logger.info("收到 KeyboardInterrupt,正在关闭..." ) finally : if camera_device: camera_device.stop()
15.3.6 视频流协议(RTMP/RTSP/HLS) 三个协议在直播推流中都占据重要的角色:
推流 (Ingest)
: 将直播源(摄像头、屏幕录制、本地文件)发送到流媒体服务器的过程。由于对低延迟的要求较高(主播需要尽快看到观众反馈或了解传输状态),RTMP 是这个阶段最常用的协议。RTSP 有时也用于 IP 摄像头的推流。WebRTC 推流也在兴起。拉流/分发 (Delivery)
: 流媒体服务器将直播内容分发给大量观众的过程。考虑到兼容性、可伸缩性和网络适应性,HLS 和 DASH (另一种基于 HTTP 的自适应流协议) 是这个阶段的主流选择。虽然延迟较高,但可以通过 CDN 轻松分发给全球观众,并能在不同网络条件下自动调整清晰度,当然我们也会同样的学习其余两种协议
主要视频流协议对比 特性 RTMP RTSP HLS 适用场景 全称 实时消息传输协议 实时流协议 HTTP 直播流 - 开发者 Adobe IETF Apple - 传输层协议 TCP TCP/UDP HTTP - 延迟 低(1-3 秒) 最低(< 1 秒) 高(5-30 秒) RTMP 适合直播互动,HLS 适合点播 自适应比特率 有限 不支持 支持 HLS 适合不稳定网络 客户端兼容性 需要 Flash 或专用播放器 需要专用播放器 几乎所有浏览器 HLS 兼容性最好 穿透防火墙能力 一般 较差 最佳 HLS 适合严格网络环境 内容加密 可选 可选 内置支持 HLS 适合对安全性要求高的场景
RTMP
: 由于其 较低的延迟 ,历史上广泛用于 直播推流 ,即把直播源(摄像头、桌面画面)发送到流媒体服务器。虽然 Flash 已被淘汰,但 RTMP 仍然是许多直播平台接收推流的主要协议之一。它不太适合大规模分发给观众。
RTSP
: 主要设计用于 控制 媒体流的播放(类似 VCR 控制),并配合 RTP 传输数据。它的 延迟可以非常低 ,因此广泛应用于 IP 摄像头监控 、视频会议系统、以及作为流媒体服务器的内容源。直接在 Web 浏览器中播放 RTSP 比较困难。
HLS
: 由 Apple 开发,基于 HTTP 。它将视频流切分成一系列小的 .ts
(Transport Stream) 文件片段,并提供一个 .m3u8
索引文件。
认识必要的两个库 python-ffmpeg-video-streaming.
主要 简化 HLS 和 DASH 流的打包创建过程 ,提供了高级 API。也包含一些 RTMP 相关的功能封装。
opencv-python
: 虽然是计算机视觉库,但它的 cv2.VideoCapture()
函数能够 非常方便地读取 RTSP 流 (以及其他 FFmpeg 支持的视频源),逐帧进行处理。读取 RTSP 时非常常用!
[环境准备]:
1.安装 FFmpeg 命令行工具 (如果尚未安装,参考第 17 章)
2.安装核心 Python 库
1 pip install ffmpeg-python opencv-python python-ffmpeg-video-streaming
读取 RTSP 视频流 (使用 opencv-python
) 场景与用途: RTSP 协议广泛应用于 IP 摄像头和安防监控系统。opencv-python
(cv2) 库提供了一个非常简洁的接口 (cv2.VideoCapture
) 来读取 RTSP 流(以及其他 FFmpeg 支持的视频源),它会自动处理底层的连接和解码,让你能够方便地逐帧获取视频画面(作为 NumPy 数组)进行分析、处理或显示。这对于需要对摄像头画面进行实时计算机视觉任务(如目标检测、人脸识别)的应用来说非常方便。
面临的问题:
在开发和测试需要处理实时视频流(尤其是 RTSP 协议)的应用时,我们经常面临一个挑战:手边没有现成的、稳定的 RTSP 源。IP 摄像头可能尚未部署或配置复杂,而去网上搜索公开的 RTSP 测试地址,会发现它们大多已经失效或极其不稳定,这给开发调试带来了很大不便。
解决方案:使用 RTSP 模拟器
为了解决这个问题,我们可以使用 RTSP 模拟器 。这是一种软件工具,它可以在你的本地计算机上运行,将 本地视频文件 、图片序列 、甚至 其他网络流 (如 HTTP、RTMP 或另一个 RTSP 流)作为输入源,然后通过标准的 RTSP 协议将这些内容 模拟成一个摄像头实时流 向外提供服务。
这样,就有了一个 稳定、可靠、随时可用 的 RTSP 源用于开发和测试,完全摆脱了对物理设备或不稳定公网地址的依赖。
EasyRTSPServer Simulator 工具使用 下载链接
第一步:下载 RTSP_Simulator 解压,运行 EasyRTSPServer_Demo.exe【注意保证 554 端口没有被占用!】 第二步:放一个视频文件到 exe 同目录,例如:easy.mp4 第三步:用 VLC 等播放器请求 rtsp://127.0.0.1:554/easy.mp4 核心 API (cv2.VideoCapture
):
方法/属性 描述 参数 返回值/效果 cv2.VideoCapture(source)
创建一个视频捕获对象。 source
: 视频源 (RTSP URL, 文件路径, 摄像头索引)VideoCapture 对象 cap.isOpened()
检查视频源是否成功打开。 无 True
(成功) 或 False
(失败)cap.read()
从视频源读取下一帧。 无 (retval, frame)
元组。< br > retval
: bool, 是否成功读取。< br > frame
: NumPy 数组 (BGR 格式), 或失败时 None
。cap.release()
释放视频捕获对象,关闭视频源连接。 无 无 cap.get(propId)
获取视频属性(如 cv2.CAP_PROP_FRAME_WIDTH
, cv2.CAP_PROP_FPS
等)。 propId
: 属性 ID (整数常量)属性值 (浮点数) cap.set(propId, value)
设置视频属性(并非所有属性都可设置或所有摄像头都支持)。 propId
: 属性 ID < br > value
: 要设置的值True
(成功) 或 False
(失败/不支持)
代码示例: 读取并显示 RTSP 流
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 74 75 import cv2import timertsp_url = "rtsp://127.0.0.1:554/easy.mp4" reconnect_delay = 5 def read_rtsp_stream (url ): """循环读取并显示 RTSP 流""" while True : print (f"尝试连接到 RTSP 流: {url} " ) cap = cv2.VideoCapture(url) if not cap.isOpened(): print (f"错误:无法打开 RTSP 流。将在 {reconnect_delay} 秒后重试..." ) cap.release() time.sleep(reconnect_delay) continue print ("RTSP 流连接成功,开始读取帧..." ) frame_count = 0 start_time = time.time() while True : ret, frame = cap.read() if not ret: print ("错误:无法读取帧或视频流结束。准备重新连接..." ) break timestamp = time.strftime("%Y-%m-%d %H:%M:%S" ) cv2.putText(frame, timestamp, (10 , 30 ), cv2.FONT_HERSHEY_SIMPLEX, 0.8 , (0 , 255 , 0 ), 2 ) cv2.imshow("RTSP Stream Viewer (Press 'q' to quit)" , frame) frame_count += 1 if cv2.waitKey(1 ) & 0xFF == ord ('q' ): print ("用户请求退出。" ) cap.release() cv2.destroyAllWindows() return cap.release() print (f"当前连接已断开。将在 {reconnect_delay} 秒后尝试重新连接..." ) time.sleep(reconnect_delay) if __name__ == "__main__" : try : read_rtsp_stream(rtsp_url) except KeyboardInterrupt: print ("\n捕获到 Ctrl+C,正在退出..." ) finally : cv2.destroyAllWindows()
优点:
代码简洁,使用非常方便,几行代码就能开始读取帧。 直接获取 NumPy 数组,无缝对接 Python 中其他的图像处理、机器学习库。缺点: 对底层的 FFmpeg 连接参数(如 TCP/UDP 选择、缓冲大小、超时设置)控制能力较弱。 错误处理相对基础,对于网络不稳定或摄像头异常的情况,可能需要更复杂的重连和错误恢复逻辑。 15.3.7.2 推送 RTMP 直播流 (使用 ffmpeg-python
) 场景与用途: 将本地视频文件、摄像头画面、屏幕录制或其他视频源,实时地以 RTMP 协议推送到流媒体服务器(如 SRS, Nginx-RTMP, Ant Media Server)或直播平台(如 Bilibili 直播、YouTube Live、Twitch 的 RTMP 接收地址)。这是 直播推流 最常用的方式之一。
核心概念 (FFmpeg 命令): 基本的 RTMP 推流命令结构如下:ffmpeg -re -i <输入源> [编码选项] -f flv <RTMP 推流地址>
-re
: (输入选项) 要求 FFmpeg 以输入源的 自然帧率 读取数据。这对于直播推流 非常重要 ,可以防止 FFmpeg 因为处理速度过快而瞬间将整个文件推完。-i <输入源>
: 指定你的视频来源(本地文件路径、摄像头设备标识符等)。[编码选项]
: 控制推流的音视频编码。-c copy
或 -c:v copy -c:a copy
: 如果输入源的编码格式 (通常是 H.264 视频 + AAC 音频) RTMP 协议和目标服务器 直接支持 ,使用 copy
可以 避免重新编码 ,大大降低 CPU 消耗,延迟也可能更低。-c:v libx264 -preset veryfast -crf 25 ... -c:a aac -b:a 128k ...
: 如果需要转码(例如,来源不是 H.264/AAC,或者需要调整码率/分辨率/帧率),则需要指定编码器和参数。直播推流通常选用较快的 preset
。-f flv
: (输出选项) 强制 指定输出格式为 flv
(Flash Video)。这是 RTMP 推流 必需 的容器格式。<RTMP 推流地址>
: 你的流媒体服务器或直播平台提供的 RTMP 地址,通常格式为 rtmp://<服务器地址>[:端口]/<应用名>/<流密钥>
。ffmpeg-python
实现:
我们可以使用 ffmpeg-python
来构建并执行上述命令。
代码示例: 将本地 MP4 文件推送到 RTMP 服务器
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 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 import ffmpegimport sysfrom pathlib import Pathinput_file = "input.mp4" rtmp_url = "rtmp://localhost/live/mystream" FFMPEG_PATH = "ffmpeg" def push_stream_to_rtmp (source_path: str , rtmp_destination: str ): """使用 ffmpeg-python 将视频文件推送到 RTMP 服务器""" source_obj = Path(source_path) if not source_obj.is_file(): print (f"错误: 输入文件不存在 '{source_path} '" ) return print (f"\n--- 开始推送 '{source_path} ' 到 '{rtmp_destination} ' ---" ) try : input_node = ffmpeg.input (str (source_obj), re=None ) print ("输入节点已定义 (带 -re)" ) output_node = ffmpeg.output( input_node, rtmp_destination, f='flv' , codec='copy' , **{'bsf:v' : 'h264_mp4toannexb' } if '-c:v copy' not in str (input_node) else {} ) print ("生成的命令:" , output_node.compile (cmd=FFMPEG_PATH)) print ("开始执行推流..." ) process = output_node.run_async(cmd=FFMPEG_PATH, pipe_stderr=True ) try : while True : line = process.stderr.readline().decode(errors='ignore' ) if not line: break print (f"[ffmpeg stderr] {line.strip()} " ) except Exception as e: print (f"读取 stderr 时出错: {e} " ) process.wait() if process.returncode == 0 : print ("推流正常结束。" ) else : print (f"推流异常结束,返回码: {process.returncode} " ) except ffmpeg.Error as e: print ("FFmpeg 推流失败:" ) print (e.stderr.decode(errors='ignore' )) except FileNotFoundError: print (f"错误: 输入文件 '{source_path} ' 或 ffmpeg 命令 '{FFMPEG_PATH} ' 未找到。" ) except Exception as e: print (f"执行推流时发生未知错误: {e} " ) if __name__ == "__main__" : push_stream_to_rtmp(input_file, rtmp_url)
关键参数/知识点 (ffmpeg.output
for RTMP):
kwarg FFmpeg CLI 作用 备注 filename
(输出文件名) 必需: 设置为你的 RTMP 推流地址。如 'rtmp://server/app/key'
f
-f flv
必需: 强制指定输出格式为 flv
。RTMP 协议基于 FLV 容器。 codec
-c copy
(可选) 直接复制所有流。推荐优先尝试 ,如果源编码兼容 RTMP。 效率最高,CPU 占用最低。 vcodec
-c:v <codec>
(可选, 如果不 copy) 指定视频编码器,直播通常用 libx264
。 acodec
-c:a <codec>
(可选, 如果不 copy) 指定音频编码器,直播常用 aac
。 preset
-preset <value>
(可选, 编码时) 编码预设,直播常用 'veryfast'
或 'fast'
。 tune
-tune <value>
(可选, 编码时) 编码器调优,'zerolatency'
可能有助于降低编码延迟。 video_bitrate
-b:v <rate>
(可选, 编码时) 控制视频码率。 audio_bitrate
-b:a <rate>
(可选, 编码时) 控制音频码率。 g
-g <value>
(可选, 编码时) 设置关键帧间隔 (GOP size)。对直播流很重要,建议设为帧率的 2-4 倍。 如 -g 60
(假设帧率 30)。 **{'bsf:v': '...'}
-bsf:v ...
(可选, 仅 copy 时) 比特流过滤器,如 'h264_mp4toannexb'
(从 MP4 复制 H.264 时需要)。 re=None
-re
(输入选项 ) 在 ffmpeg.input()
中使用,按源帧率读取,直播推流必需 。
注意:
你需要一个正在运行的 RTMP 服务器来接收推流。可以使用 Nginx + RTMP 模块、SRS、Ant Media Server 等自行搭建,或者使用直播平台提供的推流地址。 如果推流来源是摄像头等实时设备,需要在 ffmpeg.input()
中使用正确的设备名和格式参数(如 -f dshow -i video="My Camera"
on Windows, -f v4l2 -i /dev/video0
on Linux, -f avfoundation -i "0"
on macOS),并且通常 不需要 输入选项 -re
。 15.4 网络安全与加密 在现代软件开发中,网络通信无处不在。无论是构建 Web 应用、API 服务、移动后端还是分布式系统,数据在网络中的传输都面临着被窃听、篡改或伪造的风险。因此,网络安全 和 数据加密 成为了保护用户信息、维护系统完整性和确保业务连续性的基石。Python 凭借其丰富的标准库和第三方库生态系统,为开发者提供了强大的工具来实现安全的网络通信和数据保护。
本章将深入探讨网络安全的核心概念,特别是 TLS/SSL 协议,以及常用的数据摘要(哈希)、编码和加密技术,并结合 Python 代码示例进行实战讲解,帮助你理解其原理、应用场景和最佳实践,规避常见陷阱。
15.4.1 TLS/SSL 加密通信:保障传输通道安全 当你通过浏览器访问 https://
网站,或者当你的应用程序通过网络与服务器安全地交换数据时,TLS (Transport Layer Security) 及其前身 SSL (Secure Sockets Layer) 协议就在幕后默默工作。它们是保障网络通信通道安全的事实标准。
想象一下没有 TLS/SSL 的互联网通信,就像是在大街上用明信片传递非常私密的信息:
1.谁都能看(无保密性): 任何在网络路径上的中间节点(你的网络提供商、同一 Wi-Fi 下的其他人、黑客控制的路由器等)都能轻易读取你的数据,比如用户名、密码、银行卡号、聊天内容。
2.谁都能改(无完整性): 中间人可以截获你的数据,修改后再发给对方,而接收方可能毫无察觉。比如,把转账金额改掉,或者在网页里注入恶意脚本。
3.你不知道对方是谁(无身份认证): 你连接一个网站或服务时,无法确定对方真的是你想连接的目标。攻击者可以伪造一个看起来一模一样的网站(钓鱼网站)来骗取你的信息。这就是所谓的 中间人攻击 (Man-in-the-Middle, MitM) 。
本小结的代码核心不是教你 ssl
模块的 API 语法,而是强调:
网络通信默认不安全,必须主动采取措施(使用 TLS/SSL)来保障数据传输的机密性、完整性和通信双方的身份真实性。Python 的 ssl
模块提供了实现这些安全保障的标准工具。理解并正确使用这些工具对于开发任何需要网络交互的安全应用都至关重要。
TLS/SSL 的关键特性与安全意义 理解 TLS/SSL 提供的安全保障对于正确使用它至关重要。
特性 描述 安全意义 日常场景举例 加密通信 数据在客户端和服务器之间传输时,使用协商好的对称密钥进行加密。 防止网络中间的 窃听者 (如 Wi-Fi 热点上的攻击者)读取敏感信息。 在线银行交易、登录凭据提交、API 数据传输。 身份验证 通常,客户端会验证服务器提供的 数字证书 ,以确认服务器的真实身份。 防止 中间人攻击 (Man-in-the-Middle, MitM) ,确保你连接的是目标服务器,而非冒名顶替者。 浏览器验证网站证书,显示安全锁标志。 数据完整性 使用消息认证码 (MAC) 或类似机制,确保数据在传输过程中没有被修改。 防止数据在传输途中被 篡改 ,例如修改交易金额或注入恶意内容。 确保下载的文件未被损坏或植入病毒。 前向保密 (PFS) (可选,但推荐)即使服务器的长期私钥泄露,过去的会话密钥也无法被解密。 即使发生最坏情况(私钥泄露),也能保护 历史通信 的机密性,增强长期安全。 现代 Web 服务器(如 Nginx, Apache)通常默认启用。
注释 :虽然 SSL 是早期版本,且存在已知漏洞,现在应始终优先使用 TLS(当前推荐版本为 TLS 1.2 和 TLS 1.3)。Python 的 ssl
模块默认会尝试使用安全的协议版本。
Python 中的 TLS/SSL 支持:ssl
模块 Python 的内置 ssl
模块提供了创建 TLS/SSL 安全连接的接口,可以包装标准的 socket
对象。
示例 1:创建一个安全的 TLS/SSL 客户端 这个例子演示了如何连接到一个公共 HTTPS 网站 (www.python.org
),验证其证书,并进行简单的 HTTP 请求。
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 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 import socketimport sslimport datetimeimport pprint def secure_tls_client_example (host="www.python.org" , port=443 ): """ 演示创建一个安全的 TLS/SSL 客户端连接。 Args: host (str): 目标服务器主机名。 port (int): 目标服务器端口号 (HTTPS 默认为 443)。 """ print (f"--- 开始连接到 {host} :{port} ---" ) context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH) try : sock = socket.create_connection((host, port), timeout=10 ) except socket.gaierror as e: print (f"错误:无法解析主机名 {host} : {e} " ) return except socket.timeout: print (f"错误:连接 {host} :{port} 超时" ) return except socket.error as e: print (f"错误:无法建立 TCP 连接到 {host} :{port} : {e} " ) return try : ssl_sock = context.wrap_socket(sock, server_hostname=host) print (f"成功建立 TLS 连接到 {ssl_sock.server_hostname} (IP: {ssl_sock.getpeername()} )" ) except ssl.SSLCertVerificationError as e: print (f"错误:服务器证书验证失败: {e} " ) sock.close() return except ssl.SSLError as e: print (f"错误:TLS 握手失败: {e} " ) sock.close() return except Exception as e: print (f"错误:包装套接字时发生未知错误: {e} " ) sock.close() return try : print ("\n--- TLS 连接详情 ---" ) print (f"TLS 版本: {ssl_sock.version()} " ) cipher_details = ssl_sock.cipher() print (f"密码套件: {cipher_details[0 ]} " ) print (f"协议版本: {cipher_details[1 ]} " ) print (f"密钥位数: {cipher_details[2 ]} " ) server_cert = ssl_sock.getpeercert() print ("\n--- 服务器证书信息 ---" ) pprint.pprint(server_cert) if server_cert: subject_dict = dict (x[0 ] for x in server_cert.get('subject' , [])) issuer_dict = dict (x[0 ] for x in server_cert.get('issuer' , [])) print (f"\n 主要信息提取:" ) print (f" 主题 (Subject CN): {subject_dict.get('commonName' , 'N/A' )} " ) print (f" 颁发者 (Issuer CN): {issuer_dict.get('commonName' , 'N/A' )} " ) try : formats_to_try = ["%b %d %H:%M:%S %Y %Z" , "%Y%m%d%H%M%SZ" ] not_before, not_after = None , None for fmt in formats_to_try: try : not_before = datetime.datetime.strptime(server_cert['notBefore' ], fmt) not_after = datetime.datetime.strptime(server_cert['notAfter' ], fmt) break except ValueError: continue if not_before and not_after: print (f" 有效期: {not_before.strftime('%Y-%m-%d' )} 至 {not_after.strftime('%Y-%m-%d' )} " ) else : print (" 有效期: 无法解析日期格式" ) now = datetime.datetime.now() if not_after < now: print (" 警告: 证书已过期!" ) elif (not_after - now).days < 30 : print (f" 警告: 证书将在 {(not_after - now).days} 天内过期!" ) except KeyError: print (" 有效期: 证书信息中缺少日期字段" ) except Exception as e: print (f" 有效期: 解析日期时出错: {e} " ) print ("\n--- 发送 HTTP GET 请求 ---" ) request = f"GET / HTTP/1.1\r\nHost: {host} \r\nConnection: close\r\nUser-Agent: Python-SecureClient/1.0\r\nAccept: */*\r\n\r\n" ssl_sock.sendall(request.encode('utf-8' )) print ("请求已发送。" ) print ("\n--- 接收响应 ---" ) response_parts = [] while True : try : chunk = ssl_sock.recv(4096 ) if not chunk: break response_parts.append(chunk) except ssl.SSLWantReadError: print ("等待更多数据..." ) continue except socket.timeout: print ("错误:接收响应超时" ) break except ssl.SSLError as e: print (f"错误:接收数据时发生 SSL 错误: {e} " ) break except socket.error as e: print (f"错误:接收数据时发生套接字错误: {e} " ) break response = b"" .join(response_parts) print (f"收到 {len (response)} 字节的响应。" ) try : decoded_response = response.decode('utf-8' , errors='ignore' ) print ("\n--- 响应预览 (前 500 字符) ---" ) print (decoded_response[:500 ] + ("..." if len (decoded_response) > 500 else "" )) except Exception as e: print (f"解码响应时出错: {e} " ) print ("原始响应 (前 500 字节):" ) print (response[:500 ]) except ssl.SSLError as e: print (f"错误:在 TLS 通信期间发生错误: {e} " ) except socket.error as e: print (f"错误:在通信期间发生套接字错误: {e} " ) except Exception as e: print (f"错误:发生意外错误: {e} " ) finally : print ("\n--- 关闭连接 ---" ) if 'ssl_sock' in locals () and ssl_sock: try : ssl_sock.close() print ("SSL/TLS 套接字已关闭。" ) except Exception as e: print (f"关闭 SSL 套接字时出错: {e} " ) if __name__ == "__main__" : secure_tls_client_example(host="blog.csdn.net" )
示例 2:创建一个基础的 TLS/SSL 服务器 这个例子需要你自己生成一个服务器证书和私钥文件。
前提:生成自签名证书和私钥
在你的项目目录下,使用 OpenSSL 命令行工具执行:
1 2 3 4 5 6 7 8 9 openssl req -new -x509 -days 365 -nodes -out cert.pem -keyout key.pem -subj "/C=CN/ST=Beijing/L=Beijing/O=MyOrg/OU=MyDept/CN=localhost"
Python 服务器代码:
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 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 import socketimport sslimport threadingimport os def handle_secure_client (ssl_client_sock, client_addr ): """处理单个安全客户端连接的线程函数""" print (f"线程 {threading.get_ident()} : 处理来自 {client_addr} 的连接" ) try : print (f" 客户端 TLS 版本: {ssl_client_sock.version()} " ) print (f" 客户端使用密码套件: {ssl_client_sock.cipher()[0 ]} " ) request_data = ssl_client_sock.recv(1024 ) if not request_data: print (f" 客户端 {client_addr} 未发送数据,提前关闭连接。" ) return print ( f" 收到 {len (request_data)} 字节数据: {request_data.decode('utf-8' , errors='ignore' )[:80 ]} ..." ) response_body = f""" <html> <head><title>Secure Server Response</title></head> <body> <h1>Hello from Secure Python Server!</h1> <p>Your connection from {client_addr} is encrypted using {ssl_client_sock.cipher()[0 ]} .</p> </body> </html> """ response_headers = f"""HTTP/1.1 200 OK Content-Type: text/html; charset=utf-8 Content-Length: {len (response_body.encode('utf-8' ))} Connection: close Server: Python-SecureServer/1.0 """ full_response = response_headers.encode('utf-8' ) + response_body.encode('utf-8' ) ssl_client_sock.sendall(full_response) print (f" 已向 {client_addr} 发送响应。" ) except ssl.SSLError as e: print (f"错误:与客户端 {client_addr} 通信时发生 SSL 错误: {e} " ) except socket.error as e: print (f"错误:与客户端 {client_addr} 通信时发生套接字错误: {e} " ) except Exception as e: print (f"错误:处理客户端 {client_addr} 时发生意外错误: {e} " ) finally : try : ssl_client_sock.shutdown(socket.SHUT_RDWR) except : pass ssl_client_sock.close() print (f"线程 {threading.get_ident()} : 与 {client_addr} 的连接已关闭。" ) def create_secure_tls_server (host='localhost' , port=8443 , certfile='cert.pem' , keyfile='key.pem' ): """ 创建并运行一个基础的多线程 TLS/SSL 服务器。 Args: host (str): 服务器绑定的主机地址。 '0.0.0.0' 表示监听所有网络接口。 port (int): 服务器监听的端口号。 certfile (str): 服务器证书文件路径。 keyfile (str): 服务器私钥文件路径。 """ if not os.path.exists(certfile) or not os.path.exists(keyfile): print (f"错误:证书文件 '{certfile} ' 或密钥文件 '{keyfile} ' 不存在。" ) print ("请先使用 openssl 生成:" ) print ("openssl req -new -x509 -days 365 -nodes -out cert.pem -keyout key.pem -subj \"/CN=localhost\"" ) return context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) try : context.load_cert_chain(certfile=certfile, keyfile=keyfile) print ("服务器证书和私钥加载成功。" ) except ssl.SSLError as e: print (f"错误:加载证书/密钥失败: {e} " ) print (f"请确保证书 '{certfile} ' 和密钥 '{keyfile} ' 文件有效且格式正确。" ) return except FileNotFoundError: print (f"错误:找不到证书文件 '{certfile} ' 或密钥文件 '{keyfile} '。" ) return try : bindsocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) bindsocket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1 ) server_address = (host, port) bindsocket.bind(server_address) bindsocket.listen(5 ) print (f"安全服务器启动,正在监听 {server_address} ..." ) except socket.error as e: print (f"错误:无法绑定或监听端口 {port} : {e} " ) print ("端口可能已被占用,或者没有权限绑定到该地址。" ) return except Exception as e: print (f"错误:服务器套接字设置失败: {e} " ) return try : while True : print ("\n等待新的客户端连接..." ) try : client_sock, client_addr = bindsocket.accept() print (f"接受来自 {client_addr} 的 TCP 连接。" ) except socket.error as e: print (f"警告:接受连接时出错: {e} 。继续等待..." ) continue try : ssl_client_sock = context.wrap_socket(client_sock, server_side=True ) print (f"与 {client_addr} 的 TLS 握手成功。" ) client_handler = threading.Thread( target=handle_secure_client, args=(ssl_client_sock, client_addr), daemon=True ) client_handler.start() except ssl.SSLError as e: print (f"错误:与 {client_addr} 的 TLS 握手失败: {e} " ) client_sock.close() except socket.error as e: print (f"错误:在包装套接字或与 {client_addr} 握手期间发生错误: {e} " ) client_sock.close() except Exception as e: print (f"错误:处理新连接 {client_addr} 时发生未知错误: {e} " ) client_sock.close() except KeyboardInterrupt: print ("\n检测到 Ctrl+C,服务器正在关闭..." ) except Exception as e: print (f"\n服务器主循环发生意外错误: {e} " ) finally : print ("关闭服务器监听套接字。" ) bindsocket.close() if __name__ == "__main__" : create_secure_tls_server() import socket import sslimport threadingimport os def handle_secure_client (ssl_client_sock, client_addr ): """处理单个安全客户端连接的线程函数""" print (f"线程 {threading.get_ident()} : 处理来自 {client_addr} 的连接" ) try : print (f" 客户端 TLS 版本: {ssl_client_sock.version()} " ) print (f" 客户端使用密码套件: {ssl_client_sock.cipher()[0 ]} " ) request_data = ssl_client_sock.recv(1024 ) if not request_data: print (f" 客户端 {client_addr} 未发送数据,提前关闭连接。" ) return print ( f" 收到 {len (request_data)} 字节数据: {request_data.decode('utf-8' , errors='ignore' )[:80 ]} ..." ) response_body = f""" <html> <head><title>Secure Server Response</title></head> <body> <h1>Hello from Secure Python Server!</h1> <p>Your connection from {client_addr} is encrypted using {ssl_client_sock.cipher()[0 ]} .</p> </body> </html> """ response_headers = f"""HTTP/1.1 200 OK Content-Type: text/html; charset=utf-8 Content-Length: {len (response_body.encode('utf-8' ))} Connection: close Server: Python-SecureServer/1.0 """ full_response = response_headers.encode('utf-8' ) + response_body.encode('utf-8' ) ssl_client_sock.sendall(full_response) print (f" 已向 {client_addr} 发送响应。" ) except ssl.SSLError as e: print (f"错误:与客户端 {client_addr} 通信时发生 SSL 错误: {e} " ) except socket.error as e: print (f"错误:与客户端 {client_addr} 通信时发生套接字错误: {e} " ) except Exception as e: print (f"错误:处理客户端 {client_addr} 时发生意外错误: {e} " ) finally : try : ssl_client_sock.shutdown(socket.SHUT_RDWR) except : pass ssl_client_sock.close() print (f"线程 {threading.get_ident()} : 与 {client_addr} 的连接已关闭。" ) def create_secure_tls_server (host='localhost' , port=8443 , certfile='cert.pem' , keyfile='key.pem' ): """ 创建并运行一个基础的多线程 TLS/SSL 服务器。 Args: host (str): 服务器绑定的主机地址。 '0.0.0.0' 表示监听所有网络接口。 port (int): 服务器监听的端口号。 certfile (str): 服务器证书文件路径。 keyfile (str): 服务器私钥文件路径。 """ if not os.path.exists(certfile) or not os.path.exists(keyfile): print (f"错误:证书文件 '{certfile} ' 或密钥文件 '{keyfile} ' 不存在。" ) print ("请先使用 openssl 生成:" ) print ("openssl req -new -x509 -days 365 -nodes -out cert.pem -keyout key.pem -subj \"/CN=localhost\"" ) return context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) try : context.load_cert_chain(certfile=certfile, keyfile=keyfile) print ("服务器证书和私钥加载成功。" ) except ssl.SSLError as e: print (f"错误:加载证书/密钥失败: {e} " ) print (f"请确保证书 '{certfile} ' 和密钥 '{keyfile} ' 文件有效且格式正确。" ) return except FileNotFoundError: print (f"错误:找不到证书文件 '{certfile} ' 或密钥文件 '{keyfile} '。" ) return try : bindsocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) bindsocket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1 ) server_address = (host, port) bindsocket.bind(server_address) bindsocket.listen(5 ) print (f"安全服务器启动,正在监听 {server_address} ..." ) except socket.error as e: print (f"错误:无法绑定或监听端口 {port} : {e} " ) print ("端口可能已被占用,或者没有权限绑定到该地址。" ) return except Exception as e: print (f"错误:服务器套接字设置失败: {e} " ) return try : while True : print ("\n等待新的客户端连接..." ) try : client_sock, client_addr = bindsocket.accept() print (f"接受来自 {client_addr} 的 TCP 连接。" ) except socket.error as e: print (f"警告:接受连接时出错: {e} 。继续等待..." ) continue try : ssl_client_sock = context.wrap_socket(client_sock, server_side=True ) print (f"与 {client_addr} 的 TLS 握手成功。" ) client_handler = threading.Thread( target=handle_secure_client, args=(ssl_client_sock, client_addr), daemon=True ) client_handler.start() except ssl.SSLError as e: print (f"错误:与 {client_addr} 的 TLS 握手失败: {e} " ) client_sock.close() except socket.error as e: print (f"错误:在包装套接字或与 {client_addr} 握手期间发生错误: {e} " ) client_sock.close() except Exception as e: print (f"错误:处理新连接 {client_addr} 时发生未知错误: {e} " ) client_sock.close() except KeyboardInterrupt: print ("\n检测到 Ctrl+C,服务器正在关闭..." ) except Exception as e: print (f"\n服务器主循环发生意外错误: {e} " ) finally : print ("关闭服务器监听套接字。" ) bindsocket.close() if __name__ == "__main__" : create_secure_tls_server()
如何测试服务器:
确保 cert.pem
和 key.pem
在同一目录。 运行包含 create_secure_tls_server()
的 Python 脚本。 打开浏览器,访问 https://localhost:8443
。你会看到一个安全警告! 这是因为浏览器不信任你的自签名证书(它不是由受信任的证书颁发机构 CA 签发的)。你需要手动选择“高级”或“继续前往”(具体措辞因浏览器而异)才能访问。成功访问后,你应该能看到服务器返回的 HTML 页面。 你也可以使用 curl
或修改上面的 secure_tls_client_example
连接到 localhost:8443
来测试。使用 curl
时,需要加上 -k
或 --insecure
选项来忽略证书验证:curl -k https://localhost:8443
修改客户端连接 localhost
时,如果客户端默认验证证书,也会失败。你需要配置客户端信任你的自签名证书(高级)或暂时禁用验证(仅用于测试! )。 15.4.2 数据摘要与哈希函数 (Hashing):验证数据完整性 哈希函数(也称散列函数或摘要函数)是一种将任意长度的输入数据通过一个数学算法,转换成一个固定长度的输出(哈希值或摘要)的过程。
核心特性:
单向性 (One-way): 从哈希值几乎不可能反向推导出原始输入数据。确定性 (Deterministic): 相同的输入总是产生相同的输出哈希值。固定长度输出: 无论输入多大,输出的哈希值长度总是固定的(例如 SHA-256 输出总是 256 位)。抗碰撞性 (Collision Resistance): 弱抗碰撞性: 给定一个输入 $ M_1 $ ,难以找到另一个输入 $ M_2 $ 使得 $ Hash(M_1) = Hash(M_2) $ 。强抗碰撞性: 难以找到任意两个不同的输入 $ M_1 $ 和 $ M_2 $ 使得 $ Hash(M_1) = Hash(M_2) $ 。主要应用场景:
数据完整性校验: 计算文件的哈希值,传输或存储后再次计算哈希值进行比较,判断文件是否被篡改。例如,软件下载网站提供的 MD5/SHA 校验码。密码存储: 不直接存储用户密码明文,而是存储密码的哈希值(通常加盐处理)。用户登录时,计算输入密码的哈希值与存储的哈希值比较。数据索引/查找: 哈希表(如 Python 的字典 dict
)使用哈希函数快速定位数据。数字签名: 对消息的哈希值进行签名,而非整个消息,提高效率。Python 中的哈希支持:hashlib
模块 Python 的内置 hashlib
模块提供了对多种安全哈希和消息摘要算法的接口,包括 MD5, SHA-1, SHA-2 系列 (SHA-224, SHA-256, SHA-384, SHA-512), 以及 SHA-3 系列和 BLAKE2 等。
常用哈希算法对比与选择 算法 输出长度 (位) 安全性评估 推荐场景 hashlib
构造器MD5 128 不安全! 已发现严重碰撞漏洞,绝对禁止 用于安全目的。仅限 于非安全相关的文件校验和(例如检查下载是否完整)。hashlib.md5()
SHA-1 160 不安全! 已被攻破,禁止 用于安全目的。遗留系统兼容(非常不推荐)。 hashlib.sha1()
SHA-256 256 当前安全标准。 广泛应用。数据完整性、密码存储(需加盐)、数字签名。 hashlib.sha256()
SHA-512 512 更安全。 在 64 位系统上可能更快。需要更高安全性的场景,性能要求不高时。 hashlib.sha512()
SHA-3 可变 NIST 标准,不同于 SHA-2 的内部结构,提供替代方案。 新项目可选,尚未如 SHA-2 般普及。 hashlib.sha3_256()
, etc.BLAKE2 可变 通常比 MD5, SHA-1, SHA-2, SHA-3 更快且同样安全。 高性能哈希计算。 hashlib.blake2b()
, blake2s()
强烈建议 :对于新的安全应用,至少使用 SHA-256 。绝对避免使用 MD5 和 SHA-1 。
示例 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 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 74 75 76 77 78 79 80 import hashlibdef calculate_hashes (data_bytes ): """ 计算给定字节数据的多种哈希值。 Args: data_bytes (bytes): 需要计算哈希的原始数据 (必须是 bytes 类型)。 Returns: dict: 包含不同算法哈希值 (十六进制字符串) 的字典。 """ if not isinstance (data_bytes, bytes ): raise TypeError("输入数据必须是 bytes 类型" ) results = {} md5_hash = hashlib.md5() md5_hash.update(data_bytes) results['MD5' ] = md5_hash.hexdigest() sha1_hash = hashlib.sha1() sha1_hash.update(data_bytes) results['SHA-1' ] = sha1_hash.hexdigest() sha256_hash = hashlib.sha256() sha256_hash.update(data_bytes) results['SHA-256' ] = sha256_hash.hexdigest() sha512_hash = hashlib.sha512() sha512_hash.update(data_bytes) results['SHA-512' ] = sha512_hash.hexdigest() return results message = "这是一个需要计算哈希值的示例文本。" message_bytes = message.encode('utf-8' ) hashes = calculate_hashes(message_bytes) print (f"原始消息: '{message} '" )print ("计算得到的哈希值:" )for algo, hash_value in hashes.items(): print (f" {algo:<8 } : {hash_value} " ) file_path = 'my_document.txt' try : with open (file_path, 'wb' ) as f: f.write(b"This is the content of the file.\n" ) f.write(b"Hashing ensures data integrity.\n" ) print (f"\n计算文件 '{file_path} ' 的 SHA-256 哈希值..." ) file_sha256 = hashlib.sha256() buffer_size = 65536 with open (file_path, 'rb' ) as f: while True : data_chunk = f.read(buffer_size) if not data_chunk: break file_sha256.update(data_chunk) print (f"文件 '{file_path} ' 的 SHA-256: {file_sha256.hexdigest()} " ) except FileNotFoundError: print (f"\n错误:文件 '{file_path} ' 未找到,跳过文件哈希示例。" ) except Exception as e: print (f"\n处理文件时出错: {e} " )
示例 2:安全的密码存储(加盐哈希) 直接存储密码的哈希值是不够的,因为攻击者可以使用 彩虹表 (Rainbow Tables) —— 预先计算好的常见密码哈希值列表 —— 来快速破解。为了防御彩虹表攻击,我们需要为每个密码添加一个 随机盐 (Salt) ,然后将密码和盐一起哈希。
基本流程:
注册时: 为新用户生成一个唯一的、随机的盐值 (Salt)。 将用户输入的密码和盐值拼接(或其他组合方式)。 使用安全的哈希算法(如 SHA-256)计算拼接后结果的哈希值。 将 盐值 和 哈希值 一起存储在数据库中(绝不能存储明文密码! )。 登录时: 根据用户名从数据库中取出对应的 盐值 和 存储的哈希值 。 将用户输入的密码和取出的盐值以 相同的方式 拼接。 计算拼接后结果的哈希值。 将计算出的哈希值与数据库中存储的哈希值进行比较。如果相同,则密码正确。 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 import hashlibimport os def generate_salt (length: int = 16 ) -> bytes : """生成一个指定长度的安全随机盐值 (bytes)。""" return os.urandom(length) def hash_password (password: str , salt: bytes ) -> bytes : """使用 SHA-256 和给定的盐来哈希密码。""" if isinstance (password, str ): password = password.encode('utf-8' ) if not isinstance (salt, bytes ): raise TypeError("盐值必须是 bytes 类型" ) password_salt_combo = salt + password hashed_password = hashlib.sha256(password_salt_combo).digest() return hashed_password def verify_password (stored_hash: bytes , provided_password: str , salt: bytes ) -> bool : """验证提供的密码是否与存储的哈希值匹配 (使用相同的盐)。""" new_hash = hash_password(provided_password, salt) import hmac return hmac.compare_digest(stored_hash, new_hash) def check_user_login (is_valid: bool ): """检查用户名和密码是否匹配。""" if is_valid: print ("登录成功!密码匹配。" ) else : print ("登录失败!密码错误。" ) if __name__ == '__main__' : password_attempt = "Prorise@qq.com" user_salt = generate_salt() stored_password_hash = hash_password(password_attempt, user_salt) print ("--- 模拟用户注册 ---" ) print (f"用户密码: {password_attempt} " ) print (f"生成的盐值 (hex): {user_salt.hex ()} " ) print (f"存储的哈希值 (hex): {stored_password_hash.hex ()} " ) login_password_attempt = "Prorise@qq.com" retrieved_salt = user_salt retrieved_hash = stored_password_hash print ("\n--- 模拟用户登录 ---" ) print (f"尝试登录密码: {login_password_attempt} " ) is_valid = verify_password(retrieved_hash, login_password_attempt, retrieved_salt) check_user_login(is_valid)
进阶与替代方案 :虽然上述加盐哈希比简单哈希好得多,但现代密码存储更推荐使用 专为密码哈希设计的算法 ,如 bcrypt , scrypt , Argon2 (Argon2id 最佳)。这些算法是 计算密集型 的(可以调整工作因子),使得即使有盐,暴力破解的成本也极高。Python 中可以通过 bcrypt
库或 argon2-cffi
库使用它们。hashlib
也提供了 pbkdf2_hmac
,它是一种密钥派生函数,也可用于密码哈希,允许配置迭代次数。
HMAC:带密钥的哈希,用于消息认证 HMAC (Hash-based Message Authentication Code) 结合了哈希函数和一个 秘密密钥 ,用于同时验证 数据完整性 和 消息来源的真实性 。
工作原理 (简化):
发送方和接收方共享一个秘密密钥。 发送方使用该密钥和哈希函数(如 SHA-256)计算消息的 HMAC 值。 发送方将原始消息和计算出的 HMAC 值一起发送给接收方。 接收方使用 相同的密钥 和哈希函数,对收到的原始消息独立计算 HMAC 值。 接收方比较自己计算的 HMAC 值与收到的 HMAC 值。如果相同,则消息未被篡改,并且确实来自持有共享密钥的发送方。 1 2 3 4 5 6 7 8 9 flowchart LR K["共享密钥 K"] --> S1["发送方\n计算 HMAC"] S1 --> S2["发送\n消息 + HMAC"] S2 --> R1["接收方\n收到 消息 + HMAC"] R1 --> R2["计算 HMAC'"] R2 --> C{"HMAC' == HMAC?"} C -- 是 --> OK["验证通过"] C -- 否 --> NG["验证失败"]
与普通哈希的区别:
普通哈希只能验证完整性(任何人都可以计算哈希),不能验证来源。 HMAC 因为需要秘密密钥,所以可以验证来源(只有持有密钥的人才能计算出正确的 HMAC)。 应用场景:
确保 API 请求未被篡改且来自合法客户端。 验证 Webhook 请求的来源。 安全协议中的消息认证。 Python 中的 HMAC 支持:hmac
模块 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 import hmacimport hashlibdef generate_hmac (key, message ): """使用 SHA-256 和给定密钥计算消息的 HMAC。""" if isinstance (key, str ): key = key.encode('utf-8' ) if isinstance (message, str ): message = message.encode('utf-8' ) h = hmac.new(key, message, hashlib.sha256) return h.hexdigest() def verify_hmac (key, message, received_hmac_hex ): """验证接收到的 HMAC 是否与基于密钥和消息计算出的 HMAC 匹配。""" calculated_hmac_hex = generate_hmac(key, message) return hmac.compare_digest(calculated_hmac_hex, received_hmac_hex) shared_secret_key = "my-super-secret-and-long-key" message_to_send = "This is an important message that needs authentication." hmac_generated = generate_hmac(shared_secret_key, message_to_send) print ("--- 发送方 ---" )print (f"消息: {message_to_send} " )print (f"密钥: {shared_secret_key} " )print (f"生成的 HMAC (SHA-256): {hmac_generated} " )received_message = "This is an important message that needs authentication." received_hmac = hmac_generated print ("\n--- 接收方 ---" )print (f"收到的消息: {received_message} " )print (f"收到的 HMAC: {received_hmac} " )print (f"使用的密钥: {shared_secret_key} " )is_authentic = verify_hmac(shared_secret_key, received_message, received_hmac) if is_authentic: print ("消息验证成功!数据完整且来源可信。" ) else : print ("消息验证失败!数据可能被篡改或来源不可信。" ) tampered_message = "This is an important message that has been TAMPERED!!!" print (f"\n--- 模拟篡改 ---" )print (f"篡改后的消息: {tampered_message} " )is_tampered_authentic = verify_hmac(shared_secret_key, tampered_message, received_hmac) if not is_tampered_authentic: print ("篡改后的消息验证失败 (预期结果)。" ) wrong_key = "another-key" print (f"\n--- 模拟错误密钥 ---" )is_wrong_key_authentic = verify_hmac(wrong_key, received_message, received_hmac) if not is_wrong_key_authentic: print ("使用错误密钥验证失败 (预期结果)。" )
踩坑点: HMAC 的安全性 严重依赖于密钥的保密性 。如果密钥泄露,HMAC 就失去了验证来源的作用。密钥管理是使用 HMAC 的关键挑战。
15.4.3 数据编码 (Data Encoding):安全传输的变形术 在深入探讨“加密”以保护数据机密性之前,我们先来理解一个相关但不同的概念——编码 (Encoding) 。
编码 vs. 加密:核心区别
目的不同: 编码 :主要目的是 转换数据格式 ,使其适应特定的传输协议、存储系统或媒介,确保数据能够被正确、无损地处理。它 不提供保密性 。加密 :主要目的是 保护数据内容 ,防止未经授权的访问(保密性)。它通常还需要保证数据的完整性和来源真实性。可逆性与密钥: 编码 :算法是 公开 的,过程通常是 完全可逆 的,不需要密钥 。知道编码算法就能解码。加密 :算法可以是公开的,但必须使用 密钥 。只有持有正确密钥的人才能解密。简单来说,编码是为了让数据“说”目标系统能听懂的“语言”,而加密是为了让数据“说”只有授权者能听懂的“密语”。
本节我们重点介绍两种在网络开发中极其常见的编码方式:Base64 和 URL 编码。
Base64 编码: 为什么需要 Base64?
想象一下,你想在一段 JSON 数据(纯文本格式)中包含一张小图片(二进制数据),或者通过电子邮件发送一个程序文件。很多基于文本的协议或格式无法直接处理原始的二进制字节流,因为:
二进制数据可能包含 控制字符 (如 NULL 字节 \x00
),这些字符在某些文本处理系统中可能有特殊含义或导致截断。 文本协议通常基于特定的 字符集 (如 ASCII, UTF-8),直接嵌入任意二进制数据可能导致解析错误。 Base64 就是为了解决这个问题而设计的。它定义了一种方法,可以将 任何二进制数据 映射为仅包含 64
个安全、可打印的 ASCII 字符的序列。
字符集:
A-Z
(26 个)a-z
(26 个)0-9
(10 个)+
(加号)/
(斜杠)总计 62 个。另外还有用于填充的 =
字符。第 63 和 64 个字符 (+
, /
) 在某些场景(如 URL 或文件名)中不安全,因此存在一个 URL 安全的 Base64 变种 ,它使用 -
(减号) 替换 +
,使用 _
(下划线) 替换 /
。
工作原理简述:
Base64 将输入的二进制数据按 每 3 个字节 (24 位) 分组,然后将这 24 位再拆分成 4 个 6 位 的单元。每个 6 位的单元可以表示 $ 2^6 = 64 $ 个不同的值,正好对应 Base64 字符集中的一个字符。如果原始数据字节数不是 3 的倍数,会在末尾进行 填充 (Padding) ,通常用 =
字符表示填充了多少字节。
应用场景:
嵌入数据: 在 JSON, XML, YAML 等文本格式中嵌入二进制内容。Data URLs: 在 HTML 或 CSS 中直接嵌入小型资源(如图片、字体),格式为 data:[<mediatype>][;base64],<data>
。例如 data:image/png;base64,iVBORw0KGgo...
。邮件传输 (MIME): 编码邮件附件。HTTP Basic Authentication: 将 username:password
字符串进行 Base64 编码后放入 Authorization
请求头。证书文件: PEM 格式的证书文件(如 .crt
, .pem
)中间的内容就是 Base64 编码的二进制证书数据 (DER 格式)。安全警示:Base64 ≠ 加密!
这是最重要的提醒! Base64 编码是完全公开、可逆的转换。任何知道数据是 Base64 编码的人都可以轻松地将其解码回原始二进制内容。绝对不能用 Base64 来“隐藏”或“保护”任何敏感信息,如密码、私钥、API Key 等。 它仅仅是一种格式转换工具,不提供任何机密性保障。
Python 中的 Base64 支持:base64
模块
Python 内置的 base64
模块提供了方便的函数来进行标准 Base64 和 URL 安全 Base64 的编解码。
核心函数:
函数 描述 输入类型 输出类型 注意事项 base64.b64encode(s)
对字节串 s
进行标准 Base64 编码。 bytes
bytes
base64.b64decode(s)
对标准 Base64 编码的字节串 s
进行解码。 bytes
bytes
输入 s
必须是有效的 Base64 格式,否则抛异常 base64.urlsafe_b64encode(s)
对字节串 s
进行 URL 安全的 Base64 编码。 bytes
bytes
使用 -
和 _
替代 +
和 /
base64.urlsafe_b64decode(s)
对 URL 安全的 Base64 编码的字节串 s
进行解码。 bytes
bytes
代码示例:
1 2 3 4 5 6 7 8 9 10 11 12 import base64data = "Hello, World!" encoded_data = base64.b64encode(data.encode('utf-8' )) print ("Encoded data:" , encoded_data)decoded_data = base64.b64decode(encoded_data).decode('utf-8' ) print ("Decoded data:" , decoded_data)
URL 编码 (Percent-Encoding): 为什么需要 URL 编码?
URL 的设计有着严格的字符限制。根据 RFC 3986 标准:
允许的字符: 字母 (A-Z
, a-z
)、数字 (0-9
) 以及 -
, .
, _
, ~
这些字符可以直接出现在 URL 中,不需要编码。保留字符 (Reserved Characters): : / ? # [ ] @ ! $ & ' ( ) * + , ; =
这些字符在 URL 中有特殊的语法含义(例如 :
分隔协议和主机,/
分隔路径段,?
分隔路径和查询,&
分隔查询参数等)。如果想在路径或查询参数值中 表示这些字符本身 ,而不是它们的特殊含义,就必须进行编码。不安全字符: 空格、引号、<
, >
, {
, }
, |
, \
, ^
, `
等字符,以及所有 ASCII 控制字符和非 ASCII 字符,都 不允许 直接出现在 URL 中,必须进行编码。URL 编码(或称百分号编码)就是将这些需要编码的字符表示为其 UTF-8 (或其他指定编码) 字节序列的 %XX
形式,其中 XX
是该字节的两位十六进制表示。
应用场景:
构建 URL 查询字符串: 当你需要将用户输入或其他动态数据放入 URL 的查询参数时,必须对这些数据进行编码,以防特殊字符破坏 URL 结构或引起歧义。HTML 表单提交: 当浏览器以 application/x-www-form-urlencoded
方式提交表单时,会对表单字段名和值进行 URL 编码。路径中包含特殊字符: 虽然不太常见,但如果 URL 路径段本身需要包含保留字符(如 /
),也需要编码。Python 中的 URL 编码支持:urllib.parse
模块
urllib.parse
模块提供了处理 URL 各部分的函数,包括编码和解码。
核心函数:
函数 描述 对空格的处理 适用场景 (一般) urllib.parse.quote(string, safe='/')
对字符串进行 URL 编码。默认情况下,/
字符 不会 被编码 (因为常用于路径)。可以传入 safe
参数指定哪些字符不编码。 编码为 %20
URL 路径 (Path) 部分 urllib.parse.unquote(string)
对 URL 编码的字符串进行解码。 %20
解码为空格解码路径或查询部分 urllib.parse.quote_plus(string)
类似于 quote
,但 默认会将空格编码为 +
号 。它不接受 safe
参数,所有保留字符都会编码(除了 -._~
)。 编码为 +
URL 查询 (Query) 部分 urllib.parse.unquote_plus(string)
类似于 unquote
,但会将 +
号解码为空格。 +
解码为空格解码查询部分 urllib.parse.urlencode(query)
接收一个字典或 (key, value) 对列表,将其编码成 URL 查询字符串 (如 key1=value1&key2=value2
)。默认使用 quote_plus
对键和值进行编码。 内部使用 quote_plus
方便地构建整个查询字符串
代码示例:
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 from urllib.parse import quote, unquote, quote_plus, unquote_plus, urlencode, parse_qsfile_name = "report for Q1/2025&draft.pdf" encoded_path_segment = quote(file_name, safe='' ) encoded_path_segment_keep_slash = quote(file_name) print ("--- 对路径编码 ---" )print (f"原始文件名: '{file_name} '" )print (f"编码 (safe=''): {encoded_path_segment} " ) print (f"编码 (默认 safe='/'): {encoded_path_segment_keep_slash} " ) decoded_path = unquote(encoded_path_segment_keep_slash) print (f"解码后: '{decoded_path} '" )assert decoded_path == file_nameparam_value = "搜索: Python & C++ ?" encoded_query_value = quote_plus(param_value) print ("\n--- 对查询参数编码 ---" )print (f"原始参数值: '{param_value} '" )print (f"quote_plus 编码后: {encoded_query_value} " ) decoded_query_value = unquote_plus(encoded_query_value) print (f"unquote_plus 解码后: '{decoded_query_value} '" )assert decoded_query_value == param_valueparams = { 'query' : '网络安全 & 加密' , 'lang' : 'zh-cn' , 'results per page' : 10 } query_string = urlencode(params) print ("\n--- 使用 urlencode 构建查询字符串 ---" )print (f"参数字典: {params} " )print (f"生成的查询字符串: {query_string} " ) parsed_params = parse_qs(query_string) print (f"服务器端解析结果: {parsed_params} " )base_url = "https://example.com/search" full_url = f"{base_url} ?{query_string} " print (f"最终 URL: {full_url} " )
15.4.4 对称加密 (Symmetric Encryption):共享同一把钥匙的保险箱 对称加密,顾名思义,就是指在加密 (将明文数据转换为不可读的密文)和解密 (将密文还原为明文)这两个过程中,使用完全相同的密钥 (Secret Key) 。
核心思想与流程:
可以把它想象成一个保险箱和一把物理钥匙:
共享钥匙: 发送方 Alice 和接收方 Bob 必须在通信开始前,通过某种安全 的方式获得并保管好同一把 钥匙 $ K $ 。锁上(加密): Alice 使用钥匙 $ K $ 锁上保险箱(使用对称加密算法和密钥 $ K $ 对明文 $ P $ 进行加密,得到密文 $ C $ )。运输: Alice 将锁上的保险箱(密文 $ C $ )通过普通快递(不安全的网络)发送给 Bob。打开(解密): Bob 收到保险箱后,使用他手中那把相同的钥匙 $ K $ 打开保险箱(使用相同的对称解密算法和密钥 $ K $ 对密文 $ C $ 进行解密,恢复出明文 $ P $ )。优点:
缺点:
密钥分发困难: 如何安全地共享密钥是核心难题。密钥管理复杂: 密钥的生成、存储、分发、轮换、销毁等环节都需要极其小心。DES 和 3DES (Data Encryption Standard):昔日标准,【绝对避免】 DES: 56 位密钥,极其不安全 ,已被完全破解。禁止使用! 3DES: DES 的改良版,速度慢,分组大小有缺陷,也被认为过时且不安全 。禁止在新系统中使用! 核心建议: 彻底忘记 DES 和 3DES,它们在现代密码学中没有位置。
AES (Advanced Encryption Standard):现代对称加密的基石 AES (高级加密标准),算法原名 Rijndael,是当前最广泛使用、最受信任 的对称加密行业标准 。
分组大小 (Block Size): 固定为 128 位 (16 字节) 。密钥长度 (Key Length): 支持 128 位、192 位、256 位 。AES-128
: 速度最快,安全性足够应对当前绝大多数场景。AES-192
: 安全性更高。AES-256
: 目前最高安全级别,用于长期或极高安全要求。安全性: 算法本身极为健壮,安全性依赖于密钥保密 和正确的使用方式(操作模式与 IV/Nonce 管理) 。15.4.4.1 AES 块加密操作模式 - 【使用 AES 的关键!】 AES 本身一次只能处理 128 位的数据块。要加密更长的数据,必须采用一种操作模式 来定义如何链接这些块的加密过程。错误的选择或使用模式将彻底摧毁 AES 的安全性!
ECB (Electronic Codebook)
模式:【极其危险,禁止使用!】原理: 每个 128 位明文块都使用相同的密钥独立加密。致命缺陷: 相同的明文块产生相同的密文块! 这会暴露数据的内在模式。想象一下加密一张图片,相同颜色的区域加密后依然会呈现出相同的“加密”色块,导致图片轮廓可见。结论:绝对不要使用 ECB 模式进行任何需要保密的加密! CBC (Cipher Block Chaining)
模式:【常用,需正确管理 IV】原理: 在加密当前明文块前,先将其与前一个密文块 进行异或 (XOR)。第一个明文块与一个初始化向量 (IV) 进行异或。IV (Initialization Vector): 必须随机且唯一: 对于使用相同密钥 的每一次加密操作,必须生成一个新的、随机的、不可预测的 IV 。重复使用 IV 会严重破坏安全性。无需保密: IV 可以公开传输,通常放在密文的前面。解密时需要使用这个 IV。填充 (Padding): 由于 CBC 要求明文是块大小(16 字节)的整数倍,如果原始数据长度不足,需要在最后一个块尾部填充字节。解密后需要移除填充。优点: 隐藏了明文模式(相同明文块在不同位置密文不同)。缺点: 加密过程是串行 的,不能并行化。 不提供内置的完整性/认证保护 :仅仅是加密。攻击者可能在不知道密钥的情况下篡改密文(例如比特翻转攻击),导致解密出损坏或被恶意修改的数据,而接收方可能无法察觉(除非结合 MAC)。适用场景: 过去广泛用于文件和数据库加密,但现在更推荐使用 GCM 等认证加密模式。GCM (Galois/Counter Mode)
模式:【强烈推荐,认证加密】原理: GCM 是一种认证加密 模式 (AEAD - Authenticated Encryption with Associated Data)。它不仅提供数据机密性 (加密),还同时提供数据完整性 和来源真实性 (认证),类似于将加密(如 CTR 模式)和消息认证码(如 GMAC)高效地结合在一起。Nonce (Number used once): 必须唯一: 对于使用相同密钥 的每一次加密操作,必须使用唯一的 Nonce 。Nonce 的长度通常推荐为 96 位 (12 字节)。重复使用 Nonce 会导致灾难性的安全问题,可能暴露密钥! 无需保密: Nonce 通常与密文一起传输。AAD (Associated Authenticated Data): 可以包含附加的、不需要加密但需要保护其完整性 的数据(例如,消息头、元数据、协议版本号等)。AAD 会参与认证标签的计算。 认证标签 (Authentication Tag): GCM 在加密结束后会生成一个固定长度(通常 128 位/16 字节)的认证标签。 这个标签必须 与密文、Nonce(以及 AAD,如果使用的话)一起传输。 解密时,接收方不仅需要密钥、Nonce、密文,还需要这个标签。解密过程会重新计算标签并与收到的标签比较。只有标签匹配,解密才会成功 ,这证明了数据在传输中未被篡改,并且确实是由持有相同密钥和 Nonce 的发送方生成的。 优点: 一体化安全: 同时解决机密性、完整性、真实性。高性能: 加密和解密的核心操作(基于 CTR 模式)可以并行化。通常无需填充。 缺点: 实现相对复杂一些(但好的库如 cryptography
会封装好)。 对 Nonce 的唯一性要求极高,是使用的关键安全点。 适用场景: 现代安全通信(如 TLS 1.2/1.3)、网络协议、需要高保障的数据加密等的首选模式。 其他模式 CTR (Counter Mode): 将计数器加密后与明文异或。可并行、无需填充。常作为 GCM 的基础。本身不提供认证。CFB (Cipher Feedback), OFB (Output Feedback): 也是流式处理模式,各有特点,相对 GCM/CTR 使用较少。 15.4.4.2 Python 实现 AES 加密 (使用 cryptography
库) Python 标准库不直接提供易用的 AES 功能。强烈推荐使用现代、维护良好且功能强大的 cryptography
库。
安装:
1 pip install cryptography
示例 1:使用 AES-GCM (推荐模式) 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 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 import osfrom cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modesfrom cryptography.hazmat.backends import default_backendfrom cryptography.exceptions import InvalidTag def generate_aes_key (key_size: int = 256 ) -> bytes : """生成指定位数的随机 AES 密钥 (bytes)""" if key_size not in [128 , 192 , 256 ]: raise ValueError("密钥大小必须是128, 192 or 256位" ) return os.urandom(key_size // 8 ) def generate_nonce (nonce_size: int = 12 ): """生成指定字节数的随机 Nonce (bytes),GCM 推荐 12 字节 (96 位)。""" return os.urandom(nonce_size) def ensure_bytes (data, encoding='utf-8' ): """确保数据是字节类型,如果不是则转换为字节类型""" if data is not None and not isinstance (data, bytes ): return str (data).encode(encoding) return data def encrypt_aes_gcm (key: bytes , plaintext: bytes , associated_data: bytes = None ) -> tuple [bytes , bytes , bytes ] | None : """使用 AES-GCM 模式加密数据 Args: key: AES 加密密钥,应为 128、192 或 256 位(16、24 或 32 字节) plaintext: 需要加密的明文数据 associated_data: 关联数据(可选),不会被加密但会被包含在认证中 Returns: 成功时返回一个包含三个元素的元组 (随机数, 密文, 认证标签),均为字节类型; 失败时返回 None """ plaintext = ensure_bytes(plaintext) associated_data = ensure_bytes(associated_data) nonce = generate_nonce() try : encryptor = Cipher( algorithms.AES(key), modes.GCM(nonce), backend=default_backend() ).encryptor() if associated_data: encryptor.authenticate_additional_data(associated_data) ciphertext = encryptor.update(plaintext) + encryptor.finalize() tag = encryptor.tag return nonce, ciphertext, tag except Exception as e: print (f"加密过程出错:{e} " ) return None def decrypt_aes_gcm (key: bytes , nonce: bytes , ciphertext: bytes , tag: bytes , associated_data: bytes = None ) -> bytes | None : """使用 AES-GCM 模式解密数据 Args: key: AES 加密密钥,应为 128、192 或 256 位(16、24 或 32 字节) nonce: 加密时使用的随机数,应为 12 字节 (96 位) ciphertext: 加密后的密文数据 tag: 加密后的认证标签 associated_data: 加密时提供的关联数据(可选) Returns: 成功时返回解密后的明文数据,失败时返回 None """ ciphertext = ensure_bytes(ciphertext) tag = ensure_bytes(tag) associated_data = ensure_bytes(associated_data) try : decryptor = Cipher( algorithms.AES(key), modes.GCM(nonce, tag), backend=default_backend() ).decryptor() if associated_data: decryptor.authenticate_additional_data(associated_data) plaintext = decryptor.update(ciphertext) + decryptor.finalize() return plaintext except InvalidTag: print ("[解密错误] 解密失败:认证标签无效!数据可能被篡改或密钥/Nonce/AAD 不匹配。" ) return None except Exception as e: print (f"[解密错误] AES-GCM 解密时发生其他错误: {e} " ) return None if __name__ == '__main__' : print ("--- AES-GCM 示例 ---" ) my_aes_key = generate_aes_key(256 ) print (f"使用的 AES 密钥 (Hex): {my_aes_key.hex ()} " ) secret_data = "这是需要高度保密和完整性保护的数据!" metadata = {"sender" : "Alice" , "receiver" : "Bob" , "msg_id" : 123 } import json aad_json = json.dumps(metadata, separators=(',' , ':' )).encode('utf-8' ) print (f"原始明文: '{secret_data} '" ) print (f"附加数据 (AAD): {aad_json.decode()} " ) encryption_result = encrypt_aes_gcm(my_aes_key, secret_data, aad_json) if encryption_result: nonce, ciphertext, tag = encryption_result print ("\n[加密成功]" ) print (f" Nonce (Hex): {nonce.hex ()} ({len (nonce)} bytes)" ) print (f" 认证标签 (Hex): {tag.hex ()} ({len (tag)} bytes)" ) print (f" 密文 (Hex): {ciphertext.hex ()} ({len (ciphertext)} bytes)" ) print ("\n[开始解密]" ) decrypted_data = decrypt_aes_gcm(my_aes_key, nonce, ciphertext, tag, aad_json) if decrypted_data: print ("[解密成功]" ) try : print (f" 解密后明文: '{decrypted_data.decode('utf-8' )} '" ) assert decrypted_data == secret_data.encode('utf-8' ) except UnicodeDecodeError: print (f" 解密后字节 (Hex): {decrypted_data.hex ()} " ) else : print ("[解密失败]" ) print ("\n[模拟攻击:篡改密文]" ) tampered_ciphertext = bytearray (ciphertext) if tampered_ciphertext: tampered_ciphertext[0 ] ^= 0xFF decrypted_tampered = decrypt_aes_gcm(my_aes_key, nonce, bytes (tampered_ciphertext), tag, aad_json) print ("\n[模拟使用错误 AAD 解密]" ) wrong_aad = b'{"sender":"Eve"}' decrypted_wrong_aad = decrypt_aes_gcm(my_aes_key, nonce, ciphertext, tag, wrong_aad) else : print ("[加密失败]" )
示例 2:使用 AES-CBC (如果必须使用,且理解其局限性) 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 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 import osfrom cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modesfrom cryptography.hazmat.primitives import padding from cryptography.hazmat.backends import default_backendimport osdef generate_aes_key (key_size: int = 256 ) -> bytes : """生成指定位数的随机 AES 密钥 (bytes)""" if key_size not in [128 , 192 , 256 ]: raise ValueError("密钥大小必须是128, 192 or 256位" ) return os.urandom(key_size // 8 ) def generate_iv (block_size: int = 16 ) -> bytes : """为 CBC 生成随机 IV (必须与 AES 块大小相同)。""" return os.urandom(block_size) def encrypt_aes_cbc (key: bytes , plaintext: bytes ) -> bytes | None : """使用 AES-CBC 加密 plaintext,密钥为 key。""" iv = generate_iv() try : cipher = Cipher( algorithms.AES(key), modes.CBC(iv), backend=default_backend() ).encryptor() padder = padding.PKCS7(algorithms.AES.block_size).padder() padded_data = padder.update(plaintext) + padder.finalize() ciphertext = cipher.update(padded_data) + cipher.finalize() return iv + ciphertext except Exception as e: print (f"[加密错误] AES-CBC 加密失败: {e} " ) return None def decrypt_aes_cbc (key: bytes , iv_ciphertext: bytes ) -> bytes | None : """解密 AES-CBC 数据 (假设 IV 位于密文前面)""" if not isinstance (iv_ciphertext, bytes ): print ("[解密错误] 密文必须是字节串" ) return None iv = iv_ciphertext[:16 ] ciphertext = iv_ciphertext[16 :] try : cipher = Cipher( algorithms.AES(key), modes.CBC(iv), backend=default_backend() ).decryptor() unpadder = padding.PKCS7(algorithms.AES.block_size).unpadder() padded_data = cipher.update(ciphertext) + cipher.finalize() plaintext = unpadder.update(padded_data) + unpadder.finalize() return plaintext except Exception as e: print (f"[解密错误] AES-CBC 解密失败: {e} " ) return None if __name__ == '__main__' : print ("\n\n--- AES-CBC 示例 ---" ) key = generate_aes_key(256 ) print (f"密钥: {key.hex ()} " ) plaintext = "这是用 CBC 模式加密的数据,需要注意完整性问题!" print (f"待加密数据: {plaintext} " ) ciphertext = encrypt_aes_cbc(key, plaintext.encode()) print (f"加密后数据: {ciphertext.hex ()} " ) decrypted_data = decrypt_aes_cbc(key, ciphertext) print (f"解密后数据: {decrypted_data.decode()} " ) print ("\n[模拟 CBC 密文篡改]" ) tampered_blob_cbc = bytearray (ciphertext) if len (tampered_blob_cbc) > 17 : tampered_blob_cbc[16 ] ^= 0xFF decrypted_tampered_cbc = decrypt_aes_cbc(key, bytes (tampered_blob_cbc)) if decrypted_tampered_cbc: print (" 篡改后 CBC 解密'成功',但数据已损坏:" ) print (f" 损坏的明文 (尝试解码): '{decrypted_tampered_cbc.decode('utf-8' , errors='replace' )} '" ) else : print (" 篡改后 CBC 解密失败 (可能是填充错误)。" ) print (" 结论:需要额外的 MAC 来保证 CBC 的完整性!" )
15.4.5 非对称加密 (Asymmetric Encryption):公钥与私钥的世界 非对称加密,也称为公钥加密 (Public-key Cryptography) ,是密码学领域的一项革命性发明。它与对称加密最根本的区别在于:加密和解密使用不同的密钥 。
核心概念:密钥对 (Key Pair)
非对称加密系统基于一个密钥对 ,包含两个在数学上相关联但又不同的密钥:
公钥 (Public Key): 可以(也应该)公开发布 ,任何人都可以获取。 用于加密数据 (发送给私钥持有者)或验证签名 (验证是否由私钥持有者签名)。 私钥 (Private Key): 必须由所有者严格保密 ,绝不能泄露。用于解密由对应公钥加密的数据 或创建数字签名 。 这两个密钥是配对生成 的。通过公钥几乎不可能推导出私钥(这是非对称加密安全性的数学基础)。
工作流程类比:
加密通信(像信箱):
Bob 生成一对密钥(公钥 $ Pub_B $ ,私钥 $ Pri_B $ ),并将公钥 $ Pub_B $ 公开。 Alice 想要给 Bob 发送机密消息 $ P $ 。她使用 Bob 的公钥 $ Pub_B $ 对消息进行加密,得到密文 $ C $ 。 Alice 将密文 $ C $ 发送给 Bob。网络上的任何人都可以截获 $ C $ ,但没有 Bob 的私钥是无法解密的。 Bob 收到密文 $ C $ 后,使用他自己保密的私钥 $ Pri_B $ 进行解密,恢复出明文 $ P $ 。 数字签名(像印章):
Alice 生成一对密钥(公钥 $ Pub_A $ ,私钥 $ Pri_A $ ),并将公钥 $ Pub_A $ 公开。 Alice 写了一份文件 $ M $ ,她想证明这份文件确实是她写的且未被篡改。她使用自己的私钥 $ Pri_A $ 对文件(通常是文件的哈希值)进行签名 ,生成一个数字签名 $ S $ 。 Alice 将文件 $ M $ 和签名 $ S $ 一起发送出去。 任何人(比如 Bob)收到 $ M $ 和 $ S $ 后,可以使用 Alice 的公钥 $ Pub_A $ 对签名 $ S $ 和文件 $ M $ 进行验证 。如果验证通过,Bob 就能确信这份文件确实来自 Alice 且内容未被修改过。 优点:
解决了密钥分发问题: 这是非对称加密最核心的优势。公钥可以安全地通过任何渠道分发(网站、邮件、目录服务等),无需担心被窃听。发送方只需获取接收方的公钥即可加密信息,大大简化了密钥管理。实现数字签名: 私钥的唯一性使得非对称加密能够提供:身份认证 (Authentication): 验证消息来源。数据完整性 (Integrity): 确保消息未被篡改。不可否认性 (Non-repudiation): 发送方无法否认自己发送过经过签名的消息。缺点:
速度极慢: 非对称加密算法涉及复杂的数学运算(如大数模幂运算、椭圆曲线运算),其加密和解密速度比对称加密算法(如 AES)慢几个数量级 (通常是 100 到 1000 倍甚至更多)。不适合加密大量数据: 由于速度慢,直接使用非对称加密来加密大文件或长时间的通信流是不现实 的。标准实践:混合加密 (Hybrid Encryption)
为了兼顾非对称加密的密钥管理便利性和对称加密的高效率,实际应用中(如 TLS/SSL、PGP 等)普遍采用混合加密 方案:
生成临时对称密钥: 发送方为本次通信随机生成一个一次性的对称密钥 $ K_{sym} $ (例如,一个 AES 密钥)。用对称密钥加密数据: 使用快速的对称算法(如 AES-GCM)和密钥 $ K_{sym} $ 加密主要数据 $ P $ ,得到密文 $ C_{data} $ 。用非对称密钥加密对称密钥: 使用接收方的公钥 $ Pub_{Receiver} $ 加密这个一次性对称密钥 $ K_{sym} $ ,得到加密后的密钥 $ C_{key} $ 。由于 $ K_{sym} $ 通常很短(如 128 或 256 位),非对称加密的性能开销可以接受。传输: 将加密后的对称密钥 $ C_{key} $ 和用对称密钥加密的数据 $ C_{data} $ 一起发送给接收方。解密: 接收方使用自己的私钥 $ Pri_{Receiver} $ 解密 $ C_{key} $ ,得到一次性对称密钥 $ K_{sym} $ 。 接收方再使用恢复出的对称密钥 $ K_{sym} $ 解密 $ C_{data} $ ,得到原始数据 $ P $ 。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 graph TD subgraph 发送方 Alice Ksym["生成临时对称密钥 Ksym"] P["大数据 P"] -- "使用 Ksym 加密 (AES)" --> Cdata["密文 Cdata"] PubRcv["接收方 Bob 的公钥 Pub_B"] Ksym -- "使用 Pub_B 加密 (RSA)" --> Ckey["加密的密钥 Ckey"] end Ckey -- "传输" --> R_Ckey Cdata -- "传输" --> R_Cdata subgraph 接收方 Bob PriRcv["Bob 的私钥 Pri_B"] R_Ckey -- "使用 Pri_B 解密 (RSA)" --> Dec_Ksym["得到 Ksym"] R_Cdata -- "使用 Ksym 解密 (AES)" --> Dec_P["得到数据 P"] end style Ksym fill:#lightgreen,stroke:#333 style PubRcv fill:#lightblue,stroke:#333 style PriRcv fill:#f9f,stroke:#333
RSA (Rivest–Shamir–Adleman):应用最广泛的非对称算法 RSA 是第一个实用且至今仍在广泛使用的公钥加密算法。
数学基础: 其安全性依赖于大整数分解的困难性 。即,给定两个大素数 $ p $ 和 $ q $ ,计算它们的乘积 $ N = p \times q $ 很容易;但给定 $ N $ ,想反向分解出 $ p $ 和 $ q $ 则极其困难。密钥生成: 涉及生成两个大素数,并进行一系列模幂运算来得到公钥和私钥对。密钥长度: RSA 的安全性直接取决于密钥(模数 N)的长度(位数)。 【安全警告】 :随着计算能力的提升,较短的 RSA 密钥已不再安全。1024 位:绝对不安全! 已被证明可以被破解。2048 位:当前推荐的最低长度 ,用于一般性需求。3072 位或 4096 位:推荐用于需要更高、更长期安全的场景 。使用过短的密钥是常见的安全风险。 填充方案 (Padding) - 【极其重要!】 原始的 RSA 算法(教科书式 RSA)本身存在严重的安全缺陷 ,容易受到多种攻击(如选择密文攻击)。绝对不能直接使用原始 RSA! 必须 结合安全的填充方案 来使用 RSA。填充是在加密/签名前对原始数据进行处理,加入随机性或特定结构,以抵抗各种攻击。用于加密: 推荐:OAEP (Optimal Asymmetric Encryption Padding) ,通常结合 SHA-256 或更强的哈希函数。【弃用警告】 :PKCS#1 v1.5 填充 (较旧的标准)用于加密时存在已知的安全漏洞,不应再用于新的加密应用 。用于签名: 推荐:PSS (Probabilistic Signature Scheme) ,通常结合 SHA-256 或更强的哈希函数。PSS 提供了更强的理论安全证明(抗选择消息攻击)。兼容性:PKCS#1 v1.5 填充 用于签名目前仍被广泛使用且相对安全(不像用于加密时那么脆弱),但 PSS 是更现代、更受推荐的选择。常见用途: 密钥交换: 在混合加密中加密对称会话密钥。数字签名: 验证软件、文档、证书等的来源和完整性。Python 实现 RSA (使用 cryptography
库) cryptography
库提供了对 RSA 密钥生成、管理、加密/解密(带填充)和签名/验证(带填充)的全面支持。
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 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 from cryptography.hazmat.backends import default_backendfrom cryptography.hazmat.primitives import hashesfrom cryptography.hazmat.primitives.asymmetric import padding as asym_padding from cryptography.hazmat.primitives.asymmetric import rsafrom cryptography.hazmat.primitives import serialization from cryptography.exceptions import InvalidSignature import osdef generate_rsa_key_pair (key_size: int = 2048 , public_exponent: int = 65537 ) -> tuple : """生成 RSA 公私钥对""" private_key = rsa.generate_private_key( public_exponent=public_exponent, key_size=key_size, backend=default_backend() ) public_key = private_key.public_key() return private_key, public_key def save_private_key (private_key: rsa.RSAPrivateKey, filename: str , password: str = None ) -> None : """将私钥以 PEM 格式保存到文件,可选密码保护。""" pem = private_key.private_bytes( encoding=serialization.Encoding.PEM, format =serialization.PrivateFormat.PKCS8, encryption_algorithm=serialization.BestAvailableEncryption( password.encode('utf-8' )) if password else serialization.NoEncryption() ) with open (filename, 'wb' ) as f: f.write(pem) print (f"私钥已保存到 {filename} " + (" (已加密)" if password else " (未加密)" )) def load_private_key (filename: str , password: str = None ) -> rsa.RSAPrivateKey: """从文件加载私钥,可选密码解密。""" with open (filename, 'rb' ) as f: pem = f.read() private_key = serialization.load_pem_private_key( pem, password=password.encode('utf-8' ) if password else None , backend=default_backend() ) return private_key def save_public_key (public_key, filename ): """将公钥以 PEM 格式保存到文件。""" pem = public_key.public_bytes( encoding=serialization.Encoding.PEM, format =serialization.PublicFormat.SubjectPublicKeyInfo ) with open (filename, 'wb' ) as f: f.write(pem) print (f"公钥已保存到 {filename} " ) def load_public_key (filename ): """从 PEM 文件加载公钥。""" with open (filename, 'rb' ) as f: public_key = serialization.load_pem_public_key( f.read(), backend=default_backend() ) return public_key def rsa_encrypt_oaep (public_key: rsa.RSAPublicKey, plaintext: bytes ) -> bytes | None : """使用 RSA 公钥加密数据,使用 OAEP 填充""" try : ciphertext = public_key.encrypt( plaintext, asym_padding.OAEP( mgf=asym_padding.MGF1(algorithm=hashes.SHA256()), algorithm=hashes.SHA256(), label=None ) ) return ciphertext except Exception as e: print (f"RSA 加密失败:{e} " ) return None def rsa_decrypt_oaep (private_key, ciphertext: bytes ) -> bytes | None : """使用私钥和 OAEP 填充解密少量数据。""" try : plaintext = private_key.decrypt( ciphertext, asym_padding.OAEP( mgf=asym_padding.MGF1(algorithm=hashes.SHA256()), algorithm=hashes.SHA256(), label=None ) ) return plaintext except Exception as e: print (f"RSA 解密失败:{e} " ) return None def rsa_sign_pss (private_key, message: bytes ) -> bytes | None : """使用私钥和 PSS 填充对消息进行签名 (实际签的是哈希)。""" try : signature = private_key.sign( message, asym_padding.PSS( mgf=asym_padding.MGF1(hashes.SHA256()), salt_length=asym_padding.PSS.MAX_LENGTH ), hashes.SHA256() ) return signature except Exception as e: print (f"[签名错误] RSA PSS 签名失败: {e} " ) return None def rsa_verify_pss (public_key: rsa.RSAPublicKey, message: bytes , signature: bytes ) -> bool : """使用公钥和 PSS 填充验证签名。""" try : public_key.verify( signature, message, asym_padding.PSS( mgf=asym_padding.MGF1(hashes.SHA256()), salt_length=asym_padding.PSS.MAX_LENGTH ), hashes.SHA256() ) return True except InvalidSignature: print ("[签名错误] RSA PSS 签名验证失败" ) return False if __name__ == '__main__' : private_key_file = "rsa_private_key.pem" public_key_file = "rsa_public_key.pem" key_password = "abc123" if os.path.exists(private_key_file) and os.path.exists(public_key_file): print ("检测到已有密钥对,加载已有密钥对..." ) try : private_key = load_private_key(private_key_file,key_password) public_key = load_public_key(public_key_file) print ("已加载密钥对" ) except Exception as e: print (f"加载密钥对失败:{e} . 重新生成密钥对..." ) private_key, public_key = generate_rsa_key_pair(key_size=2048 ) save_private_key(private_key, private_key_file, key_password) save_public_key(public_key, public_key_file) else : print ("未检测到密钥对,生成新密钥对..." ) private_key, public_key = generate_rsa_key_pair(key_size=2048 ) save_private_key(private_key, private_key_file, key_password) save_public_key(public_key, public_key_file) print ("\n--- RSA 加密/解密 (OAEP) 示例 ---" ) symmetric_key_to_transmit = os.urandom(32 ) print (f"要加密的对称密钥 (Hex): {symmetric_key_to_transmit.hex ()} " ) encrypted_key = rsa_encrypt_oaep(public_key, symmetric_key_to_transmit) if encrypted_key: print (f"RSA 加密后的密钥 (Hex, 前 32 字节): {encrypted_key[:32 ].hex ()} ..." ) print (f"加密后长度: {len (encrypted_key)} bytes" ) print ("====尝试解密====" ) decrypted_key = rsa_decrypt_oaep(private_key, encrypted_key) if decrypted_key: print (f"RSA 解密后的密钥 (Hex): {decrypted_key.hex ()} " ) assert decrypted_key == symmetric_key_to_transmit print ("解密验证成功!" ) else : print ("解密失败。" ) else : print ("加密失败。" )
其他重要非对称算法:ECC (椭圆曲线密码学) 除了 RSA,椭圆曲线密码学 (Elliptic Curve Cryptography, ECC) 是另一大类非常重要的非对称算法。
优势: ECC 最大的优点在于它能用更短的密钥长度 达到与 RSA 相同的安全级别。例如:256 位的 ECC 密钥提供的安全性 ≈ 3072 位的 RSA 密钥。 384 位的 ECC 密钥提供的安全性 ≈ 7680 位的 RSA 密钥。 结果: 性能更高: 更短的密钥意味着更快的计算速度(加密、解密、签名、验证)。带宽和存储更少: 密钥和签名都更小。应用: ECC 在性能和资源受限的环境(如移动设备、物联网设备、智能卡)中特别有优势,并且越来越多地被用于:数字签名: ECDSA (Elliptic Curve Digital Signature Algorithm) 是常用的标准。密钥协商: ECDH (Elliptic Curve Diffie–Hellman) 用于安全地生成共享密钥(TLS 1.3 中广泛使用)。比特币等加密货币也大量使用 ECC。 cryptography
库支持: Python 的 cryptography
库也提供了对 ECC 密钥对生成、ECDH 密钥交换和 ECDSA 签名/验证的支持(例如 ec.generate_private_key
, ec.ECDH
, ec.ECDSA
)。注释: 虽然 RSA 仍非常流行,但 ECC 代表了现代非对称加密的一个重要发展方向,尤其是在性能和效率方面。