
简介这份源代码包围绕C网络编程中TCP粘包与丢包的处理展开面向已具备基础Socket概念、希望把收发细节从应用层剥离的开发者也适合作为网络通讯课程或项目的对照案例。工程包含服务器与客户端两端实现将连接管理、数据收发、协议头和消息结构定义、回调注册等环节做了封装调用者只需关注自身消息结构与业务回调便可观察粘包拆包在完整工程中的落地方式。压缩包共36个文件以16个.h头文件和8个.cpp源文件为主体另含dsw、dsp工程配置、rc界面资源、lib静态库及txt说明等整体约49KB体量轻便却保留了可编译运行所需的项目结构。目前已有1650人学习。对想理解传输层封装、互斥保护、消息分发与双端联调思路的读者这份代码提供了较完整的参考骨架可据此排查收发边界问题并迁移到自己的C网络通讯项目中。1. 粘包不是玄学一份能编译、能压测的 C TCP 处理工程TCP粘包这个词在网上被讲得很玄要么是一张字节流示意图要么是一句TCP 是面向字节流的就带过。真到写 C 网络通讯代码的时候多数人卡住的不是原理而是不知道缓冲区该在哪一层攒、长度字段在第几个字节读、read返回EAGAIN之后循环要不要退出。这个 C 示例工程把这三件事拆成了可编译的代码一套 12 字节的定长包头、一个带读偏移的接收缓冲、一个循环拆包的编解码器外加一个能手动制造粘包和半包的压测客户端。适合刚接手长连接服务、或者正在给自己项目补通讯层的开发者照着把 server 和 client 跑起来再在拆包回调上打个断点比看十篇讲字节流的文章都有用。2. 工程骨架与协议设计12 字节包头如何定下拆包规则协议设计决定后面所有代码的形状。包头定错一次拆包循环、内存上限、版本升级全要跟着返工这种事我见过不止一次。这个示例的取舍是固定 12 字节包头长度字段自描述包体魔数兜底流错位字段全部走网络字节序。2.1 目录分层协议、缓冲、连接、业务四层拆开先看目录。这不是为了好看每个目录对应一层职责换协议的时候只动packet_codec那一层网络代码不用碰tcp-packet-demo/ ├── CMakeLists.txt ├── include/ │ ├── packet_codec.h # 包头结构体 编解码器声明 │ ├── recv_buffer.h # 带读偏移的接收缓冲 │ └── connection.h # 连接对象fd、读写缓冲、回调 ├── src/ │ ├── packet_codec.cpp # 拆包逻辑不依赖 socket可单测 │ ├── connection.cpp # read/write、EPOLLIN/EPOLLOUT 处理 │ ├── server.cpp # epoll 主循环 业务回调注册 │ └── client.cpp # 压测客户端整包、半包、大包三种模式 └── tests/ └── codec_test.cpp # 半包、粘包、非法长度的用例分层的关键在于packet_codec.cpp里没有任何#include sys/socket.h。它只接收一个RecvBuffer从里面 peek 字节、判断完整性、消费掉一个包。这样一来半包、粘包、超长包这些最容易出错的分支都能在单测里用几行append复现不需要真的开端口、连 socket、靠sleep碰运气。实际项目里我一般也这么切协议层一旦和 fd 耦合测试成本立刻翻几倍。connection层负责三件事把 fd 上读到的字节塞进recvBuf_、循环调用 codec 拆包、把业务回调处理不完的响应写回sendBuf_。server.cpp只做 epoll 事件分发和回调注册业务代码通过setHandler挂进来改业务不用动网络层。2.2 包头布局magic、version、cmd、bodyLen 各管什么包头用#pragma pack(push, 1)压掉对齐填充保证收发两端算出来的结构体大小一致。跨平台编译时这条 pragma 在 GCC 和 MSVC 上含义略有差别但 1 字节对齐的语义是一致的// include/packet_codec.h #pragma once #include cstdint #include string #include vector #pragma pack(push, 1) struct PacketHeader { uint32_t magic; // 魔数固定 0x4E455431 uint16_t version; // 协议版本 uint16_t cmd; // 业务命令字 uint32_t bodyLen; // 包体字节数不含包头 }; #pragma pack(pop) constexpr uint32_t kMagic 0x4E455431; // NET1 constexpr size_t kHeaderLen sizeof(PacketHeader); // 12 constexpr uint32_t kMaxBodyLen 1 * 1024 * 1024; // 单包上限 1MB struct Packet { uint16_t version 0; uint16_t cmd 0; std::string body; };四个字段的用途分开说。magic放最前面作用是流错位探测器只要收到的头 4 字节对不上说明前面的字节已经错位了继续解析只会读出天文数字的长度正确做法是立即断开连接、记日志、查对端。version留给协议升级服务端可以按版本走不同的解析分支不用一次性把所有客户端升完。cmd是业务命令字业务回调里靠它分发。bodyLen只算包体、不算包头这样包头长度变化时包体长度的语义不变。bodyLen是不含包头的这点必须在文档里写清楚否则两端一个含头一个不含头差 12 个字节表现出来就是每隔几个包错一次很难查。我一般会在注释里直接写个例子bodyLen5 表示包头之后还有 5 字节。kMaxBodyLen是防炸内存的第一道闸。没有上限的话对端发一个0xFFFFFFFF的长度字段缓冲区就会一路扩容到 4GB 才等到包体到齐。1MB 是示例值实际按业务最大消息定图片、文件走分片不要塞进单个包。2.3 编译与首次运行CMake 两条命令起步构建脚本保持最小C11 就够避免为了std::span之类把工具链门槛抬高cmake_minimum_required(VERSION 3.10) project(tcp_packet_demo CXX) set(CMAKE_CXX_STANDARD 11) set(CMAKE_CXX_STANDARD_REQUIRED ON) if (NOT CMAKE_BUILD_TYPE) set(CMAKE_BUILD_TYPE Release) endif() add_library(netproto src/packet_codec.cpp src/connection.cpp ) target_include_directories(netproto PUBLIC include) add_executable(server src/server.cpp) target_link_libraries(server PRIVATE netproto) add_executable(client src/client.cpp) target_link_libraries(client PRIVATE netproto) enable_testing() add_executable(codec_test tests/codec_test.cpp) target_link_libraries(codec_test PRIVATE netproto) add_test(NAME codec_test COMMAND codec_test)netproto单独抽成静态库是为了让测试程序能直接链进来。编译和跑起来cmake -S . -B build -DCMAKE_BUILD_TYPERelease cmake --build build -j 4 ./build/server 9000 # 另开一个终端 ./build/client 127.0.0.1 9000 --count 1000 --batch ./build/client 127.0.0.1 9000 --count 10 --split第一条客户端命令用一次write塞 1000 个包服务端只要没崩溃、日志里 1000 个包的序号连续粘包处理就算通了。第二条用--split把每个包头拆成两段发、中间sleep 10ms专门验证NeedMore分支服务端不能把半截包头当成完整包头解析也不能丢掉已经收到的 6 字节。两条命令都过基本路径就稳了。server启动后默认监听0.0.0.0:9000用 epoll 水平触发。日志里会打印每条连接收到的包数、累计字节数压测完对照客户端发出去的数量一眼能看出有没有丢包或者多拆。3. 核心模块拆解从一次 read 到一次业务回调的完整链路这条链路是read→append→decode→consume→handler。中间任何一环写错表现都是偶发丢包或者跑一会儿卡死而这两种现象最难复现。下面按数据流顺序把四个关键函数拆开讲。3.1 接收缓冲append、peek、consume 三个动作缓冲用std::vectorchar加一个读偏移readPos_不每次从头部erase。原因很直接假设一个包平均 100 字节一次读进来 500 个包每拆一个包就erase一次等于把后面 5 万字节往前搬 500 次复杂度 O(n²)。连接数一多CPU 全耗在memmove上。// include/recv_buffer.h #pragma once #include cstddef #include vector class RecvBuffer { public: void append(const char* data, size_t len) { buf_.insert(buf_.end(), data, data len); // 读偏移超过一半时才搬一次把已消费的前缀清掉 if (readPos_ 0 readPos_ * 2 buf_.size()) { compact(); } } const char* peek() const { return buf_.data() readPos_; } size_t readable() const { return buf_.size() - readPos_; } void consume(size_t n) { readPos_ n; if (readPos_ buf_.size()) { buf_.clear(); readPos_ 0; } } void reserve(size_t n) { buf_.reserve(n); } private: void compact() { buf_.erase(buf_.begin(), buf_.begin() static_caststd::ptrdiff_t(readPos_)); readPos_ 0; } std::vectorchar buf_; size_t readPos_ 0; // 已消费字节数不是剩余字节数 };peek返回当前可读起点consume只移动偏移不搬内存append在偏移过半时做一次压缩。三个动作分开的好处是拆包失败比如魔数不对时可以不consume原始字节还留在缓冲里打日志能看到完整报文排查比数据已经被吃掉舒服太多。有个细节容易忽略buf_.clear()之后readPos_必须归零否则下一次peek()会越界。append里判断压缩的条件用的是readPos_ * 2 buf_.size()避免小包场景每来一次就读一次压缩。3.2 拆包循环先 peek 包头再判断包体是否到齐decode一次只处理一个包返回三种状态Ok、NeedMore、错误。状态用枚举而不是 bool因为错误要分类型——魔数错、长度超限、包体未知处理策略不一样日志里也要能区分。// include/packet_codec.h enum class DecodeResult { Ok, NeedMore, BadMagic, TooLarge }; class PacketCodec { public: DecodeResult decode(RecvBuffer buf, Packet out); };// src/packet_codec.cpp #include packet_codec.h #include arpa/inet.h #include cstring DecodeResult PacketCodec::decode(RecvBuffer buf, Packet out) { // 1. 包头都不够等下一次 read if (buf.readable() kHeaderLen) { return DecodeResult::NeedMore; } // 2. 只读不消费出错时数据还在缓冲里方便打日志 PacketHeader h; std::memcpy(h, buf.peek(), kHeaderLen); if (ntohl(h.magic) ! kMagic) { return DecodeResult::BadMagic; } const uint32_t bodyLen ntohl(h.bodyLen); if (bodyLen kMaxBodyLen) { return DecodeResult::TooLarge; } // 3. 包体没到齐等下一次 read已收到的字节不丢 if (buf.readable() kHeaderLen bodyLen) { return DecodeResult::NeedMore; } // 4. 包体到齐复制出来并消费掉整个包 out.version ntohs(h.version); out.cmd ntohs(h.cmd); out.body.assign(buf.peek() kHeaderLen, bodyLen); buf.consume(kHeaderLen bodyLen); return DecodeResult::Ok; }四个步骤对应四种状态。第一步和第三步都返回NeedMore但含义不同一个是连包头都没收全一个是包头有了、包体还差。调试时如果两种混在一起容易误判成对端没发数据。实际项目里我会把readable()的当前值打进 trace 日志看到稳定停在 6 字节就知道是对端只发了包头的前半段。bodyLen的判断顺序也有讲究先查上限再查是否到齐。反过来写的话一个 4GB 的恶意长度会先让readable() 12 4GB恒成立然后缓冲区一直等着、一直扩容直到被 OOM 杀掉。out.body用std::string而不是std::vectorchar是因为大部分业务回调最终要把包体当文本或序列化后的二进制用std::string的data()/size()在 C11 之后保证连续传指针给 protobuf 的ParseFromArray也顺手。3.3 分发循环与发送侧业务回调不能阻塞拆包拆包循环放在Connection::dispatch()里一直调到NeedMore为止。这里要防一个坑业务回调里可能把连接关掉了循环必须再判一次状态。// src/connection.cpp void Connection::dispatch() { Packet pkt; for (;;) { DecodeResult r codec_.decode(recvBuf_, pkt); if (r DecodeResult::NeedMore) { break; } if (r ! DecodeResult::Ok) { // 魔数错、长度超限都直接断开继续读只会放大错误 forceClose(protocol error); return; } if (handler_) { handler_(shared_from_this(), pkt); } if (closed_) { return; // 回调里可能已经主动关闭 } } }发送侧的问题和接收侧对称非阻塞 socket 上write可能只写出去一部分剩下的必须进发送缓冲等EPOLLOUT再补。如果直接丢掉返回值和剩余长度表现就是客户端收到半截包然后整条流错位。bool Connection::send(const void* data, size_t len) { const char* p static_castconst char*(data); ssize_t n ::write(fd_, p, len); if (n 0 (errno EAGAIN || errno EWOULDBLOCK)) { n 0; // 内核发送缓冲满不算错误 } else if (n 0) { forceClose(write error); return false; } p n; len - static_castsize_t(n); if (len 0) { if (sendBuf_.size() len kMaxSendBuf) { forceClose(send buffer full); // 背压保护 return false; } sendBuf_.append(p, len); enableWrite(); // 注册 EPOLLOUT } return true; } void Connection::onWritable() { while (!sendBuf_.empty()) { ssize_t n ::write(fd_, sendBuf_.peek(), sendBuf_.readable()); if (n 0) { sendBuf_.consume(static_castsize_t(n)); } else if (n 0 (errno EAGAIN || errno EWOULDBLOCK)) { return; // 等下次可写 } else if (n 0 errno EINTR) { continue; } else { forceClose(write error); return; } } disableWrite(); // 队列清空必须摘掉 EPOLLOUT }send先尝试直接写写不完的进缓冲。onWritable用 while 把缓冲写空空了立刻disableWrite()。这一步漏掉的话水平触发模式下EPOLLOUT会一直就绪epoll 每轮都返回CPU 空转。kMaxSendBuf用来做背压对端读得慢发送缓冲涨到上限就断开而不是无限制吃内存。3.4 事件循环接线EPOLLIN 里一次读到 EAGAINonReadable用一个 64KB 的栈缓冲循环读直到EAGAIN为止。ET 模式下这是必须的LT 模式下虽然不强制但一次读干净能减少 epoll 唤醒次数。void Connection::onReadable() { char stackBuf[64 * 1024]; for (;;) { ssize_t n ::read(fd_, stackBuf, sizeof(stackBuf)); if (n 0) { recvBuf_.append(stackBuf, static_castsize_t(n)); continue; } if (n 0) { // 对端正常关闭 forceClose(peer closed); return; } if (errno EINTR) continue; if (errno EAGAIN || errno EWOULDBLOCK) break; forceClose(read error); return; } dispatch(); // 读完再统一拆包 }把append和dispatch分开是为了让一个包里包含多个消息和一个消息被拆到多个包里走同一条路径先尽量把内核缓冲读空再在内存里循环拆。如果每读一次就拆一次逻辑也能对但系统调用次数上去了而且拆包边界和 read 边界重叠后日志更难对照。n 0必须当成关闭处理不是读到 0 字节可以继续。这个分支漏掉对端关连接后 epoll 会一直报可读read一直返回 0循环空转。4. 边界压测连续 1000 个包、1MB 大包和 ET 模式下的读循环正常路径通了不代表能用粘包问题的复现条件往往只在压力下出现对端一次塞几千个包、单个包逼近上限、内核缓冲满了之后部分写。下面三种场景是必须手动造出来的。4.1 压测客户端一次 write 塞 1000 个包先让客户端把 1000 个包拼进一个std::string一次write出去最大化粘包概率// client.cpp 节选 std::string batch; batch.reserve(1000 * (kHeaderLen 32)); for (int i 0; i 1000; i) { std::string body req- std::to_string(i); PacketHeader h {}; h.magic htonl(kMagic); h.version htons(1); h.cmd htons(1001); h.bodyLen htonl(static_castuint32_t(body.size())); batch.append(reinterpret_castconst char*(h), kHeaderLen); batch.append(body); } // 客户端自己的 write 也可能只写出去一部分同样要循环 size_t off 0; while (off batch.size()) { ssize_t n ::write(sock, batch.data() off, batch.size() - off); if (n 0) { off static_castsize_t(n); continue; } if (n 0 errno EINTR) continue; if (n 0 (errno EAGAIN || errno EWOULDBLOCK)) continue; perror(write); return; }客户端拼出来的是一段几十 KB 的连续字节服务端一次read只拿到前 64KB剩余部分留在内核缓冲里等下一次EPOLLIN。这正好覆盖了一个 read 里包含多个包和包被 read 边界切开两种情形服务端的dispatch循环必须一路拆到NeedMore才算对。服务端日志里会打印conn3 packets1000 bytes...如果只看到 986 个包先别怀疑网络丢包TCP 不丢大概率是最后一个包卡在缓冲里没被拆出来去查dispatch的退出条件或者consume的长度。4.2 半包构造把包头拆成两段发粘包只是问题的一半另一半是半包。--split模式在包头中间切一刀// 先发 6 字节再发剩下的 6 字节 包体 ::write(sock, raw, 6); struct timespec ts {0, 10 * 1000 * 1000}; // 10ms nanosleep(ts, nullptr); ::write(sock, raw 6, total - 6);服务端第一次read只收到 6 字节decode在第一个判断readable() kHeaderLen就返回NeedMore10ms 后剩余字节到达dispatch被再次调用这时才真正拆出一个包。这个用例专门验证包头不完整时不能memcpy出来解读因为 6 字节的memcpy到 12 字节结构体里后 6 字节是未初始化内存读出来的bodyLen是随机值。单测里也有一条同样的用例用RecvBuffer::append模拟两次到达比开 socket 加sleep稳定得多见第 6 章。4.3 大包与恶意长度上限、断连和日志怎么留大包测试构造一个恰好 1MB 的包体std::string big(kMaxBodyLen, x); // 恰好等于上限应该通过 // std::string big(kMaxBodyLen 1, x); // 超限服务端应断开恰好等于上限要能正常收发超过一个字节就要被TooLarge拦下。这条边界最容易写错成结果是业务允许的最大包永远发不出去而且因为包头已经消费掉一部分日志里看着像对端发了个空包。恶意长度直接用原始 socket 发PacketHeader h {}; h.magic htonl(kMagic); h.version htons(1); h.cmd htons(1001); h.bodyLen htonl(0xFFFFFFFFu); // 4GB必然超限 ::write(sock, h, kHeaderLen);期望行为是服务端立即forceClose(protocol error)并打一行日志recvBuf_不扩容。验证方法是压测前后看进程 RSS涨了就是上限判断的顺序写反了。4.4 LT 与 ET读循环退出条件不一样示例默认水平触发onReadable里读到EAGAIN是顺手读干净如果改成边缘触发这个循环就是硬性要求。ET 只在内核缓冲状态变化时通知一次如果一次read之后还有残留字节没读走下一轮不会再有事件连接就卡住了——现象是压测跑着跑着某个连接不再收数据但 fd 还活着ss看内核接收队列一直有积压。判断标准很简单ET 模式下onReadable必须在EAGAIN处退出中间不能breakLT 模式下漏读最多是下一轮多一次唤醒不会卡死。从 LT 切 ET 的时候除了读循环onWritable的写空再摘 EPOLLOUT也要检查一遍两处漏任何一处都会表现为 CPU 偏高或吞吐掉底。5. 避坑排查粘包处理里最容易翻车的五个地方这一章是我自己踩过的每条都按看到什么现象、根因在哪、怎么改写。前四条在示例代码里已经规避第五条属于使用姿势问题。5.1 本机测试永远正常压测和跨机就乱现象本机client连本机server加个sleep(50ms)就一切正常部署到两台机器之间或者把发包频率提上来服务端开始报魔数错误或者业务字段解析出来是乱的。原因把recv的返回当成一个完整的包。本机回环 MTU 大、延迟低一次write一个包、read也往往一次拿一个包看起来像消息边界存在一旦网络路径上有分片、有缓冲read拿到的就是任意长度的字节片段。所谓粘包从来不是 TCP 粘的是应用层没有自己划边界。解决接收侧只认字节不认 read 边界。任何一次read返回的字节都追加进recvBuf_由decode按长度字段切发送侧一个响应也只调一次send由它自己处理排队。这条改完sleep就可以删了。5.2 长度字段忘了网络字节序小包正常大包爆炸现象小于 256 字节的包收发正常超过之后服务端报TooLarge或者一直等包体到齐日志里打出来的bodyLen是65536、16777216这种整齐的数。原因发送端htonl漏了或者接收端ntohl漏了。小端机器上长度 256 发出去在网络序里是0x00000100对端按小端读成0x00010000正好是 65536。小于 256 的数两种序下只差在高位字节低位字节一样所以看起来正常。解决序列化只在一处做encode函数里统一htonl/htonsdecode里统一ntohl/ntohs中间层不许再出现裸的uint32_t长度字段。单测里加一条长度 300 的包往返这个 bug 一次就露馅。5.3 每次拆完包就 erase 缓冲头部CPU 越跑越高现象连接数上来之后top里这个进程的sys和usr都高火焰图上看时间全在memmove偶发还会崩在迭代器失效上。原因用std::vector::erase(begin(), begin() n)模拟消费掉一个包。一次读进来 500 个包就搬 500 次内存复杂度 O(n²)如果回调里还持有指向缓冲内部的指针erase之后指针就悬空了。解决读偏移加定期压缩就是第 3 章RecvBuffer的写法。回调拿到的包体是assign出来的独立std::string不引用缓冲内部内存缓冲压缩不影响业务。压缩阈值不要设成每次消费都压设成读偏移过半再压小包场景能省掉大量搬移。5.4 只注册了 EPOLLIN大响应写一半就丢现象请求小的时候一切正常返回一个几百 KB 的响应客户端收到的内容比预期短然后整条流解析错位后续所有包都报魔数错误。原因非阻塞 socket 的write返回值被忽略了。内核发送缓冲满的时候write只写出去一部分就返回已写字节数剩下的没人管更糟的情况是返回-1/EAGAIN代码直接当成功继续往下走。解决所有发送走统一的send先尝试写写不完进sendBuf_并注册EPOLLOUTonWritable里写空再摘事件。还要加发送缓冲上限对端长时间不读就断开别让一个慢客户端把整个进程的内存拖垮。5.5 业务回调里做阻塞操作拆包循环被拖死现象单个连接正常几十个连接一起上整体吞吐骤降strace看到线程卡在磁盘 IO 或者数据库调用上期间这个 epoll 线程上所有连接都不再被处理。原因dispatch在 epoll 线程里同步调业务回调回调里做了同步落盘、同步 RPC、或者大循环计算。拆包本身很快但回调一慢整个事件循环就停在那一个连接上。解决回调里只做解析和投递耗时操作丢给工作线程池处理完再通过send回包。工作线程回包时要注意线程安全send可能被 epoll 线程和 worker 同时调用sendBuf_要加锁或者用eventfd把写事件唤醒回 epoll 线程统一处理——示例工程在Connection::post里留了这个钩子。6. 二次开发把示例包头换成自己的业务协议拿到这个工程之后多数人要做的第一件事是换协议。换的时候别急着改decode先想清楚三件事字段要不要变、序列化用什么、老客户端怎么兼容。改造点做法验证方式加字段如序列号 seq在PacketHeader里加uint32_t seqkHeaderLen自动变成 16收发两端同步升级半包用例喂 8 字节确认返回NeedMore换序列化包体从裸字符串换成 protobuf/JSONdecode里只负责切出body解析放到业务层用一个畸形包体确认返回业务错误而不是断连加心跳新增cmd0x0001server里记录lastActiveMs定时器扫超时连接客户端不发心跳确认 30 秒后被踢兼容老版本用version字段分流v1 走旧布局v2 走新布局两个版本的 client 同时连互不影响改完包头回归测试只跑一条命令// tests/codec_test.cpp TEST(CodecTest, SplitHeaderAndBody) { PacketCodec codec; RecvBuffer buf; // 构造一个完整包然后按 5 / 3 / 剩余 三段喂进去 std::string raw makePacket(0x0002, hello-world); buf.append(raw.data(), 5); // 半个包头 Packet pkt; ASSERT_EQ(codec.decode(buf, pkt), DecodeResult::NeedMore); buf.append(raw.data() 5, 3); // 凑齐包头 ASSERT_EQ(codec.decode(buf, pkt), DecodeResult::NeedMore); buf.append(raw.data() 8, raw.size() - 8); // 补齐包体 ASSERT_EQ(codec.decode(buf, pkt), DecodeResult::Ok); ASSERT_EQ(pkt.body, hello-world); }这条用例的价值在于它不依赖端口、不依赖时序任何一次改包头、改长度语义、改字节序跑一遍就知道有没有破坏最基础的不变式。工程里的ctest已经挂上了它cmake --build build ctest --test-dir build两条命令就能跑。如果想再往前走一步可以把RecvBuffer换成环形缓冲省掉压缩那一次搬移或者把decode的返回改成游标式接口一次调用尽量多拆几个包、返回拆出的数量减少循环里的函数调用开销。这些属于性能优化正确性靠上面那条用例兜底。从那以后我每次动协议字段都先把codec_test里的半包用例跑一遍再开压测客户端打 1000 个包看序号连续性——这两个动作加起来不到一分钟但能省掉上线后半夜排障的几个小时。希望帮到你。本文还有配套的精品资源点击获取