2026/8/27 6:08:59

WebSocket实时聊天系统:从HTTP轮询到全双工通信的架构实践

WebSocket实时聊天系统:从HTTP轮询到全双工通信的架构实践 简介实时通信是现代Web应用的核心需求之一其技术演进从传统的HTTP短轮询、长轮询到Server-Sent EventsSSE最终发展为WebSocket全双工协议。WebSocket通过在单个TCP连接上提供双向、低延迟的数据交换从根本上解决了实时性、服务器压力和带宽效率问题其技术价值在于为在线聊天、协同编辑、实时通知等场景提供了高效的基础设施。在工程实践中基于Node.js等事件驱动平台构建WebSocket服务结合JSON消息协议、心跳保活和自动重连机制可以快速实现稳定可靠的实时通信功能。本文以在线聊天系统为例深入解析了WebSocket网关设计、消息路由、状态同步以及结合Nginx反向代理和Redis Pub/Sub的高并发扩展方案为开发者构建实时应用提供了一套完整的实践指南。1. 项目概述从HTTP轮询到WebSocket的必然选择几年前我接手一个需要实时显示订单状态的后台项目最初图省事用了HTTP长轮询。前端每隔几秒就发个请求问问服务器“有更新吗”服务器大部分时间都回答“没有。”那感觉就像你每隔五分钟就刷新一次邮箱页面看看有没有新邮件效率低下不说服务器压力也大得惊人。直到后来全面转向WebSocket才真正体会到什么叫“实时”——数据就像开了水龙头有变化就自动流过来服务器和客户端都轻松了。今天要聊的这个“基于WebSocket的实时在线聊天系统”核心就是解决这种高效、全双工的实时通信问题。简单说这个系统能让两个或多个用户在网页上实现像微信一样即时的文字、甚至图片消息收发。它不再需要客户端反复去“问”而是建立一条持久的双向通道消息可以随时从任何一端推送到另一端。这不仅仅是做个聊天窗口那么简单它背后涉及连接管理、消息协议、状态同步、安全性和高并发支撑等一系列工程挑战。无论你是想为自己的网站增加客服功能还是构建一个内部的协同工具或者单纯想学习现代实时Web技术这个项目都是一个绝佳的实践切入点。它适合有一定前后端基础比如熟悉Node.js或Java Spring以及Vue/React等前端框架希望深入理解网络协议和实时架构的开发者。2. 系统核心架构与通信协议选型2.1 为什么是WebSocket对比HTTP与SSE在决定用WebSocket之前我们必须清楚它解决了什么问题以及它的替代方案有哪些。传统的HTTP协议是典型的“请求-响应”模型客户端发起请求服务器处理并返回响应然后连接就关闭了。为了实现“实时”早期出现了几种“打补丁”的方案短轮询客户端定时比如每2秒向服务器发送HTTP请求。简单粗暴但延迟高最坏情况要等一个轮询间隔且无谓的请求多浪费资源。长轮询客户端发起请求服务器如果没新消息就“挂起”这个请求直到有消息或超时才返回。客户端收到响应后立即发起下一个请求。这比短轮询好一些减少了无效请求但每次消息传递仍然需要一次完整的HTTP请求-响应周期开销不小并且连接管理复杂。Server-Sent EventsSSE是一种服务器向客户端单向推送的技术。它基于HTTP允许服务器主动推送数据到客户端。优点是协议简单天然支持断线重连和事件ID。但缺点是单向只能服务器推客户端且浏览器兼容性虽好但在某些需要双向通信的场景如聊天中仍需配合其他HTTP请求来完成客户端到服务器的通信显得别扭。WebSocket协议的出现就是为了从根本上解决双向实时通信的问题。它在初次握手时使用HTTP协议通常是HTTP Upgrade请求一旦握手成功连接就升级为全双工的WebSocket连接。此后数据和帧可以在两者之间以极低的开销双向流动。对于在线聊天这种典型的双向、高频、低延迟场景WebSocket是毋庸置疑的首选。注意不要陷入“技术必须最先进”的陷阱。如果你的场景主要是服务器向客户端推送通知如新闻推送、股价更新且客户端不需要频繁上报数据SSE可能是更简单、资源消耗更少的选择。技术选型永远服务于业务场景。2.2 整体架构设计拆解一个健壮的在线聊天系统不能只靠一个WebSocket服务撑起来我们需要一个清晰的分层架构来保证可扩展性、可维护性和高可用性。下面是一个典型的架构设计[ 客户端 (Web/App) ] -- WebSocket -- [ WebSocket网关/消息服务器 ] | | (内部协议如TCP/RPC/消息队列) v [ 业务逻辑服务器 ] -- [ 数据库 缓存 ](这是一个逻辑示意图非mermaid图表)客户端层通常是Web浏览器使用WebSocketAPI 或封装好的库如Socket.IO客户端与服务器建立连接。移动端App也可使用对应的WebSocket库。WebSocket网关层这是系统的核心枢纽。它的职责包括连接管理维护所有活跃的WebSocket连接通常以用户ID或连接ID为键进行映射。协议处理解析和封装WebSocket帧处理Ping/Pong心跳保活。消息路由接收来自客户端的消息根据消息类型如私聊、群聊将其路由到正确的目标连接或业务逻辑层。会话管理可能关联用户的登录态处理连接认证例如在握手阶段验证Token。业务逻辑层处理核心业务。例如消息持久化将聊天消息存入数据库如MySQL、MongoDB以供历史查询。用户状态管理管理用户的在线、离线、隐身等状态并同步给相关联系人。复杂业务处理群组管理、文件上传、消息撤回、敏感词过滤等。数据层与缓存数据库存储用户信息、关系链、群组信息和聊天记录。对于消息这类时序数据可考虑时序数据库或对MySQL进行分表。缓存大量使用Redis。例如存储在线用户列表、未读消息计数、临时会话信息以及作为消息队列如Redis Pub/Sub用于网关层内部或网关与业务层之间的解耦通信。这种分离架构的好处是当用户量激增时我们可以水平扩展WebSocket网关服务器需要配合负载均衡和会话共享而业务逻辑层也可以独立扩展。2.3 消息协议设计JSON还是二进制建立了连接两边要说什么“语言”这就是消息协议。常见的有两种选择自定义二进制协议效率极高节省带宽解析快。常用于游戏、物联网等对性能极度敏感的场景。但开发调试复杂可读性差不同版本兼容性管理困难。文本协议如JSON可读性好易于调试前后端开发都熟悉扩展字段方便。虽然效率比二进制低但对于聊天系统消息频率和体积通常不会成为瓶颈JSON的便利性优势巨大。因此对于绝大多数聊天系统JSON是更实用的选择。我们需要设计一个通用的消息信封格式。例如{ type: chat_message, // 消息类型chat_message, system_notice, heart_beat, ack 等 sender: user123, receiver: user456, // 或 group:group789 content: { text: 你好在吗, timestamp: 1689327890123, msgId: a1b2c3d4 }, extra: {} // 扩展字段可放图片URL、信息等 }为每种type定义清晰的数据结构。此外必须设计应用层的心跳机制如客户端定时发送type: heart_beat服务器回应type: heart_beat_ack来检测死连接以及消息确认机制ACK来保证重要消息的可靠投递避免因网络闪断导致消息丢失。3. 关键技术实现与核心代码解析3.1 服务端实现以Node.js ws库为例Node.js因其事件驱动、非阻塞I/O的特性非常适合处理大量并发、I/O密集的WebSocket连接。我们使用轻量级的ws库来构建服务端。首先初始化一个WebSocket服务器并集成到Express应用中const express require(express); const http require(http); const WebSocket require(ws); const app express(); const server http.createServer(app); const wss new WebSocket.Server({ server }); // 用于存储在线用户连接 const onlineUsers new Map(); // key: userId, value: { ws, ... } wss.on(connection, (ws, request) { console.log(新的WebSocket连接建立); // 1. 连接认证从URL查询参数或Cookie中获取Token const urlParams new URL(request.url, http://${request.headers.host}); const token urlParams.searchParams.get(token); if (!token) { ws.close(1008, 未提供认证令牌); return; } // 验证Token获取用户ID (此处简化实际需调用JWT验证服务) let userId; try { const decoded verifyToken(token); // 假设的验证函数 userId decoded.userId; } catch (err) { ws.close(1008, 认证失败); return; } // 2. 将连接与用户关联 const userConn { ws, userId, lastActive: Date.now() }; onlineUsers.set(userId, userConn); console.log(用户 ${userId} 已上线); // 广播上线通知给其好友简化逻辑 notifyFriendsOnline(userId); // 3. 监听客户端消息 ws.on(message, async (data) { try { const message JSON.parse(data.toString()); userConn.lastActive Date.now(); // 更新活跃时间 // 根据消息类型分发处理 switch (message.type) { case chat_message: await handleChatMessage(message, userId); break; case heart_beat: handleHeartbeat(ws, message); break; // ... 处理其他类型消息 default: console.warn(未知消息类型:, message.type); } } catch (error) { console.error(消息处理错误:, error); sendError(ws, 消息格式错误或处理失败); } }); // 4. 处理连接关闭 ws.on(close, (code, reason) { console.log(连接关闭: 用户 ${userId}, 代码 ${code}, 原因 ${reason}); onlineUsers.delete(userId); notifyFriendsOffline(userId); // 广播下线通知 }); // 5. 处理错误 ws.on(error, (error) { console.error(WebSocket错误 (用户 ${userId}):, error); }); // 6. 发送连接成功消息 ws.send(JSON.stringify({ type: system, content: 连接已建立, timestamp: Date.now() })); }); // 处理私聊消息函数示例 async function handleChatMessage(message, senderId) { const { receiver, content } message; const msgToSave { senderId, receiver, content: content.text, timestamp: content.timestamp || Date.now(), msgId: content.msgId || generateMsgId() }; // 1. 消息持久化到数据库 await saveMessageToDB(msgToSave); // 2. 查找接收者在线连接 const targetConn onlineUsers.get(receiver); if (targetConn targetConn.ws.readyState WebSocket.OPEN) { // 在线直接推送 targetConn.ws.send(JSON.stringify({ type: chat_message, sender: senderId, content: msgToSave.content, timestamp: msgToSave.timestamp, msgId: msgToSave.msgId })); // 可选发送ACK回发送者 sendAck(senderId, msgToSave.msgId); } else { // 接收者离线存储为未读消息等其上线再推送 await storeOfflineMessage(receiver, msgToSave); } } // 启动服务器 server.listen(8080, () { console.log(服务器运行在 http://localhost:8080); });这段代码勾勒了一个基础但完整的服务端骨架。关键点在于连接建立时的认证、连接与用户的映射管理、消息类型的路由分发以及离线消息的处理逻辑。3.2 客户端实现原生WebSocket与心跳保活前端实现相对直接但健壮性需要考虑。以下是一个使用原生WebSocket API的Vue组件示例template div div v-formsg in messages :keymsg.id{{ msg.sender }}: {{ msg.content }}/div input v-modelinputText keyup.entersendMessage / button clicksendMessage发送/button div连接状态: {{ connectionStatus }}/div /div /template script export default { data() { return { ws: null, messages: [], inputText: , connectionStatus: 断开, heartbeatInterval: null, reconnectTimer: null, reconnectAttempts: 0, maxReconnectAttempts: 5 }; }, mounted() { this.initWebSocket(); }, beforeUnmount() { this.closeWebSocket(); }, methods: { initWebSocket() { const token localStorage.getItem(auth_token); // 从本地存储获取token if (!token) { console.error(未登录); return; } // 构建WebSocket URL将token作为查询参数 const wsUrl ws://localhost:8080?token${encodeURIComponent(token)}; this.ws new WebSocket(wsUrl); this.connectionStatus 连接中...; this.ws.onopen () { console.log(WebSocket连接成功); this.connectionStatus 已连接; this.reconnectAttempts 0; // 重置重连计数 this.startHeartbeat(); // 开始心跳 }; this.ws.onmessage (event) { try { const msg JSON.parse(event.data); this.handleIncomingMessage(msg); } catch (e) { console.error(解析消息失败:, e, event.data); } }; this.ws.onerror (error) { console.error(WebSocket错误:, error); this.connectionStatus 错误; }; this.ws.onclose (event) { console.log(连接关闭代码: ${event.code}, 原因: ${event.reason}); this.connectionStatus 断开; this.stopHeartbeat(); this.scheduleReconnect(); }; }, handleIncomingMessage(msg) { switch (msg.type) { case chat_message: this.messages.push({ id: msg.msgId, sender: msg.sender, content: msg.content, timestamp: msg.timestamp }); // 收到消息后可以发送一个ACK确认如果需要 this.sendAck(msg.msgId); break; case system: console.log(系统消息:, msg.content); break; case heart_beat_ack: // 收到心跳回应连接健康 console.debug(心跳ACK收到); break; default: console.warn(未知消息类型:, msg.type); } }, sendMessage() { if (!this.inputText.trim() || !this.ws || this.ws.readyState ! WebSocket.OPEN) { return; } const message { type: chat_message, receiver: targetUserId, // 实际应从聊天上下文获取 content: { text: this.inputText, timestamp: Date.now(), msgId: this.generateClientMsgId() } }; this.ws.send(JSON.stringify(message)); // 可选先在前端界面乐观显示 this.messages.push({ id: message.content.msgId, sender: 我, content: message.content.text, timestamp: message.content.timestamp }); this.inputText ; }, startHeartbeat() { // 每隔30秒发送一次心跳 this.heartbeatInterval setInterval(() { if (this.ws this.ws.readyState WebSocket.OPEN) { this.ws.send(JSON.stringify({ type: heart_beat, timestamp: Date.now() })); } }, 30000); }, stopHeartbeat() { if (this.heartbeatInterval) { clearInterval(this.heartbeatInterval); this.heartbeatInterval null; } }, scheduleReconnect() { if (this.reconnectAttempts this.maxReconnectAttempts) { console.log(已达最大重连次数停止重连); return; } // 指数退避策略1s, 2s, 4s, 8s... const delay Math.pow(2, this.reconnectAttempts) * 1000; console.log(将在 ${delay/1000} 秒后尝试重连...); this.reconnectTimer setTimeout(() { this.reconnectAttempts; this.initWebSocket(); }, delay); }, closeWebSocket() { this.stopHeartbeat(); if (this.reconnectTimer) clearTimeout(this.reconnectTimer); if (this.ws) { this.ws.close(1000, 页面关闭); // 正常关闭 } }, generateClientMsgId() { return client_${Date.now()}_${Math.random().toString(36).substr(2, 9)}; }, sendAck(msgId) { if (this.ws this.ws.readyState WebSocket.OPEN) { this.ws.send(JSON.stringify({ type: ack, msgId })); } } } }; /script客户端代码的核心在于连接的生命周期管理和错误恢复机制。心跳保活、自动重连采用指数退避策略是生产环境必备的健壮性保障。3.3 生产环境部署Nginx反向代理与负载均衡在本地开发时我们直接连接ws://localhost:8080。但在生产环境通常会有域名和HTTPS并且需要多台WebSocket服务器来承载负载。这时就需要Nginx作为反向代理。Nginx从1.3版本开始就支持WebSocket代理关键配置在于Upgrade和Connection头部的正确处理。以下是一个典型的Nginx配置片段http { upstream websocket_backend { # 配置多台WebSocket服务器实现负载均衡 server ws_server1:8080; server ws_server2:8080; # 可以配置负载均衡策略如ip_hash保证同一客户端连接到同一后端便于会话保持 ip_hash; } server { listen 443 ssl; server_name chat.yourdomain.com; ssl_certificate /path/to/your/cert.pem; ssl_certificate_key /path/to/your/key.pem; location /ws/ { # 核心代理配置 proxy_pass http://websocket_backend; proxy_http_version 1.1; # 以下两行是支持WebSocket的关键 proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; # 传递客户端真实信息 proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; # 超时设置WebSocket连接通常需要更长的超时时间 proxy_read_timeout 3600s; # 1小时 proxy_send_timeout 3600s; proxy_connect_timeout 75s; } # 其他静态资源或API接口配置 location / { root /path/to/your/static/files; index index.html; } } }配置好后前端连接的WebSocket URL就变成了wss://chat.yourdomain.com/ws/。Nginx负责将加密的WebSocket流量WSS解密并负载均衡到后端的多个WebSocket服务器实例。实操心得在配置Nginx代理时最容易踩的坑就是超时时间。WebSocket是长连接默认的代理超时时间如60秒太短会导致连接被意外切断。务必根据业务需要调整proxy_read_timeout和proxy_send_timeout。另外如果后端服务器需要获取客户端真实IPX-Real-IP和X-Forwarded-For头部的传递至关重要。4. 高级特性实现与性能优化4.1 消息可靠投递与离线存储基础的“在线转发离线存储”模型在3.1节已提及。但要做得更可靠需要考虑消息去重和投递状态反馈。消息去重网络延迟或客户端重发可能导致同一条消息被发送多次。我们可以在服务端为每条消息生成一个全局唯一的ID如Snowflake算法生成或者在接收消息时检查(senderId, clientMsgId, timestamp)三元组在一定时间窗口内是否已存在避免重复处理。投递状态实现“已发送”、“已送达”、“已读”三种状态。已发送服务器收到消息并持久化后立即ACK给发送者。已送达当消息成功推送到接收者在线连接时服务器记录该状态并可选择性地通知发送者。已读需要客户端在点开聊天窗口或滚动到消息位置时主动发送一个type: message_read的事件到服务器服务器更新状态并广播给发送者。这个功能对数据一致性要求高需要仔细设计。离线消息的拉取可以在用户连接建立并认证成功后由服务器主动推送或者由客户端主动发送一个同步请求如type: sync_offline_msg携带上次同步的时间戳或最后一条消息的ID。4.2 群聊与聊天室实现群聊是私聊的扩展核心区别在于一对多的消息路由。我们需要引入“群组”这个概念。数据结构在数据库中需要groups表存储群信息和group_members表存储用户与群的关联。在线成员消息路由当一条群聊消息到来时服务器需要根据groupId查询所有群成员ID。遍历成员ID从onlineUsersMap中找出所有在线的成员连接。遍历这些在线连接逐一发送消息。离线消息处理对于不在线的群成员需要为每个人单独存储一条离线消息记录或使用收件箱模型因为每个人的已读位置和离线时间不同。性能优化对于大群如500人以上遍历在线成员并逐个发送可能成为性能瓶颈。优化方法包括使用消息队列将群消息发布到一个以groupId命名的Redis Pub/Sub频道。每个WebSocket服务器实例订阅它负责的用户群相关的频道。当消息到来时由Redis负责广播给所有订阅了该频道的服务器进程再由各进程发送给其连接上的在线成员。这解耦了网关服务器便于水平扩展。批量发送如果协议支持可以将多条消息打包成一个帧发送减少网络往返和序列化开销。4.3 横向扩展与状态共享当单台WebSocket服务器无法承受连接数时必须横向扩展。这会带来一个新问题状态共享。用户A连接在服务器1上用户B连接在服务器2上他们之间如何发消息解决方案的核心是引入一个中央化的会话管理器或消息路由器。常用方案有Redis Pub/Sub如上文群聊优化所述。每台服务器在启动时将其负责的用户ID列表注册到Redis。当服务器1需要给用户B发消息时它查询到用户B在服务器2上于是向一个专属于“服务器2”的频道发布消息。服务器2订阅了自己的频道收到消息后转发给本机的用户B连接。专业的消息中间件如RabbitMQ、Apache Kafka或更专业的实时消息平台如腾讯云的IM SDK背后服务。它们提供更强大的消息路由、持久化和顺序保证。使用Sticky Session的负载均衡通过负载均衡器如Nginx的ip_hash确保同一客户端的请求总是落到同一台后端服务器。这样两个互发消息的用户如果恰好在同一台服务器上就可以直接转发。但这种方法不够灵活用户重连后IP可能变化且服务器故障时用户体验受损。在实践中Redis Pub/Sub因其简单高效常被用于中小型系统。我们需要在每台WebSocket服务器上维护两个映射localUserMap:{ userId - wsConnection }本机用户连接serverChannelMap:{ userId - targetServerChannel }用户所在服务器频道可存于Redis当收到发往userId的消息时先查localUserMap如果存在则直接发送如果不存在则从Redis查出targetServerChannel通过Redis Pub/Sub将消息发布到该频道由目标服务器处理。5. 常见问题排查与实战调试技巧5.1 连接建立失败与1006错误1006是WebSocket连接关闭时一个常见的、但规范中未明确定义的错误码。它通常表示连接在建立之前就失败了原因多种多样网络问题防火墙、代理服务器阻止了WebSocket连接。尤其是在使用WSSWebSocket Secure时证书问题也可能导致。Nginx配置错误忘记配置Upgrade和Connection头部或者代理的目标地址错误。服务器端未正确处理握手服务器代码没有正确响应HTTP Upgrade请求。检查服务器日志看握手请求是否到达以及响应头是否正确。心跳超时应用层心跳没有正确处理导致连接被中间设备如负载均衡器因空闲超时而切断。排查步骤检查浏览器开发者工具在Network标签页查看WebSocket连接看握手请求状态码101是否成功。如果失败查看响应头和状态码。检查服务器日志查看WebSocket服务器在连接建立阶段的日志是否有错误输出。简化测试尝试在服务器本地用ws://localhost:port直接连接排除网络和代理问题。检查心跳确认客户端和服务端的心跳逻辑正常运行Ping/Pong帧或应用层心跳包是否按时收发。5.2 消息延迟与吞吐量瓶颈当用户反映消息发送慢或者服务器CPU/内存飙升时可能是遇到了性能瓶颈。单个连接消息堆积如果某个客户端发送消息过快而服务器处理如写数据库较慢可能导致服务器内存中积压大量待处理消息。解决方案是背压控制在服务端设置一个缓冲区阈值当待处理消息队列超过该阈值时可以暂停从该连接读取数据通过TCP流量控制或向客户端发送“慢下来”的控制消息。广播风暴在大型群聊中一条消息需要复制给成千上万的在线连接。如果使用简单的循环发送会阻塞事件循环。必须使用异步非阻塞的方式发送。在Node.js中确保ws.send()操作是异步的并且注意ws.send()本身如果缓冲区满可能会同步阻塞可以使用ws._socket._writableState检查缓冲区状态或者使用库提供的回调/Promise接口。数据库写入瓶颈消息持久化是主要IO操作。优化方法批量写入将短时间内收到的多条消息缓冲起来一次性批量插入数据库。异步写入将写数据库操作放入消息队列如Redis List由单独的工作进程消费并写入避免阻塞主消息循环。使用更快的存储对于最新的活跃聊天记录可以先用内存如Redis存储再异步落盘到传统数据库。5.3 内存泄漏与连接管理WebSocket服务器是长连接服务连接不释放会导致内存泄漏最终使服务器崩溃。连接泄露确保在close和error事件中从onlineUsersMap等数据结构中清理对应的连接引用。否则即使TCP连接已关闭JavaScript对象仍被持有无法被垃圾回收。定时清理僵尸连接有些连接可能因为网络问题没有正常触发close事件。需要设置一个定时器定期遍历所有连接检查其最后活跃时间lastActive如果超过一定阈值如心跳间隔的3倍则主动调用ws.terminate()强制关闭并清理。监控与告警使用PM2、Docker Stats或云监控服务密切关注服务器的内存使用量、连接数、事件循环延迟等指标。设置告警阈值以便在问题恶化前介入。5.4 安全考量认证与授权必须在WebSocket握手阶段完成认证如验证JWT Token防止未授权连接。在消息处理函数中也要再次验证发送者是否有权向目标接收者发送消息。输入验证与过滤对所有从客户端接收的消息进行严格的JSON解析和字段验证防止畸形数据导致服务器崩溃。对文本内容进行敏感词过滤防止不良信息传播。流量控制防止恶意客户端发送海量消息耗尽服务器资源。可以对每个连接实施速率限制如每秒最多发送N条消息。WSS加密生产环境务必使用WSSWebSocket over TLS防止通信内容被窃听或篡改。Origin检查在WebSocket服务器端可以检查握手请求中的Origin头部只允许信任的域名建立连接防止跨站WebSocket劫持。构建一个稳定、高效的实时聊天系统就像搭建一个精密的通信网络每一个环节——从协议握手、消息路由到状态同步和异常处理——都需要仔细设计和反复打磨。从最简单的单机版开始逐步引入Redis解耦、Nginx代理、集群扩展这个过程本身就是对分布式系统理解的深化。我个人的体会是调试这类系统最有效的工具就是清晰的日志和客户端、服务端的双向状态跟踪把每一次消息的来龙去脉都记录清楚很多诡异的问题都会迎刃而解。本文还有配套的精品资源点击获取