
简介C多线程抓取网页的完整示例代码面向正在学习网络爬虫、数据抓取或网页批量下载的C开发者尤其适合有一定C基础但尚未接触过多线程与HTTP库配合的初学者。压缩包共包含十六个文件其中主要有五个头文件类与接口声明、三个源文件核心抓取与界面逻辑、一个工程文件、一个对话框资源文件等整体仅二十二KB结构精简可快速打开工程阅读。目前已有一百七十人浏览学习属于小巧实用的入门级参考。代码基于标准线程库创建抓取线程配合libcurl发送HTTP请求并使用pugixml解析返回的HTML同时演示了线程的join与detach、共享数据保护、curl全局初始化与清理等关键细节。读者可从中理解多线程与网络I/O协作的模式并在此基础上扩展任务队列、限速策略或分布式抓取逻辑构建自己的爬虫工具。1. C 多线程抓取网页这个 VC 工程把并发抓取做成了能直接跑的样本C 多线程抓取网页这件事最大的门槛不在 HTTP 协议而在并发本身队列谁来维护、线程怎么退出、抓下来的 HTML 怎么避免乱码。这个 VC 工程把整条链路做成了一个能直接编译运行的 MFC 对话框程序——界面上贴一组 URL设好线程数点开始几个工作线程就从共享队列里取任务、发请求、把页面按原始编码保存到本地。代码量不大但线程同步、状态码判断、编码转换这些坑都踩过一遍。它适合两类人想学 VC 下 WinINet 与多线程配合的初学者跟着把数据流跑通就能理解生产-消费模型要做 Windows 桌面抓取工具的开发者改一改队列和解析模块就能当爬虫底座。下面按架构、核心代码、踩坑、改造四个方向拆。2. 项目架构与数据流界面、抓取核心、协议封装三层怎么协作2.1 文件结构与依赖关系拿到工程先别急着按 F7把源码文件过一遍会更省事。这个抓取工程沿用了 VC 对话框工程最常见的分法界面层、抓取核心层、协议封装层各放各的避免网络请求和界面刷新搅在同一个文件里。我拆到的这份源码文件组织大致如下文件职责被谁依赖SpiderDemo.sln / SpiderDemo.vcxproj工程文件VS2015 及以上可直接打开-SpiderDemoDlg.cpp / .h主对话框URL 输入区、线程数设置、开始/停止按钮、日志列表顶层入口SpiderCore.cpp / .h抓取核心持有 URL 队列、启动/停止工作线程、协调整个抓取流程被对话框依赖UrlQueue.cpp / .h线程安全的 URL 队列内部封装 CRITICAL_SECTION被 SpiderCore 依赖Fetcher.cpp / .h基于 WinINet 的 HTTP 请求封装对外只暴露一个 Fetch 方法被 SpiderCore 依赖HtmlParser.cpp / .h轻量 HTML 解析提取 charset、标题、页内链接被 SpiderCore 依赖resource.h / SpiderDemo.rc对话框资源、图标与版本信息编译期依赖关系是单向的SpiderCore 依赖 UrlQueue 和 Fetcher、HtmlParser而对话框只依赖 SpiderCore。注意一个反向约束——SpiderCore 里不能出现任何 CWnd 控件调用它对外通知结果只通过 PostMessage 投递自定义消息这是后面 4.1 节那个假死问题的根源所在代码里从一开始就切断了这条线。模块拆这么细是为了改造时不用动全局。你想把 WinINet 换成 libcurl只需要重写 Fetcher队列和线程逻辑一行不用动想做断点续抓在 SpiderCore 里加一个已抓集合就够。我一般接手这类工程第一件事就是画一张这样的依赖图确认网络层和 UI 层没有直接握手再往下读代码。2.2 技术选型VC 环境下抓 HTML 的三种常见路子Windows 桌面上用 C 抓网页绕不开三套东西WinINet、WinHTTP、libcurl。这个工程选的是 WinINet理由很实际MFC 对话框工程天然带afxinet.hInternetOpenUrl一个函数调用就能拉起请求不需要额外集成第三方库编译环境干净对个人抓取工具来说功能完全够用。方案优点缺点适合场景WinINet系统自带、API 简单、自动读系统网络设置HTTP/2 支持有限、偏客户端形态MFC 桌面抓取工具WinHTTP服务端场景和并发表现更强API 更底层要自己处理的细节更多Windows 服务、后端组件libcurl跨平台、功能最全、协议支持完整需要配置 include/lib 路径工程变重跨平台爬虫、需要双向认证等高级特性选 WinINet 还有一个细节抓取场景最怕拿到 IE 缓存里的旧页面WinINet 的INTERNET_FLAG_RELOAD | INTERNET_FLAG_NO_CACHE_WRITE组合正好解决这个问题强制重新拉取并且不写缓存。WinHTTP 也有类似标志但写起来比 WinINet 繁琐不少。对这个项目来说少引入一个第三方库就少一类版本冲突问题编译不过的概率低很多。WinINet 有个天生的限制要提前知道它的会话和 IE 共享一部分缓存和代理设置所以在做高频抓取时不建议在一个进程里反复创建销毁 HINTERNET 会话句柄最好复用。这个工程的做法是在 SpiderCore 启动时创建一次会话所有工作线程共用抓完关闭。后面 3.3 节的代码里也能看到这个思路。2.3 生产者-消费者模型与线程编排整个程序运行起来就是一条典型的生产者-消费者流水线。主线程UI是生产者用户在编辑框里粘贴一批 URL点开始后SpiderCore 把每行解析出来逐条 Push 进 UrlQueue。N 个工作线程是消费者抢到一个 URL 就做一次 请求 → 解析 → 落盘 → 通知主线程然后继续抢下一个。这里有两个关键设计。第一个是任务信号的传递队列不能只有锁还得有一个事件对象Push 的时候 SetEvent 唤醒等待中的线程否则线程只能靠轮询空转会白白耗 CPU。第二个是退出协议停止按钮做的事不是强行杀掉线程而是把 m_bExit 置 TRUE 并 SetEvent让每个线程在处理完手头 URL 后自己检查退出条件。这段逻辑放在 3.2 节是整份代码里最值得细读的部分。线程数量一般默认 4 到 8。不是越大越好因为抓取瓶颈通常在网络 IO 和对端服务器的承受能力而不是本地 CPU。线程开多了反而会被目标站点限流这个问题会在 4.4 节展开。把这套模型想清楚抓取工具的核心骨架就有了后面所有功能都是往这条流水线上挂东西。3. 核心代码拆解一个 URL 从入队到 HTML 落盘的完整链路3.1 线程安全的 URL 队列CUrlQueue队列是多线程抓取的第一个共享资源所有工作线程都从这里取任务所以锁和容器必须绑在一起封装而不是在 SpiderCore 里散着放一个 CList。如果锁在外面很容易出现某个线程 Push 时忘了加锁、另一个线程又在 Pop 的场景临界区就形同虚设。// UrlQueue.h - 线程安全的 URL 队列 #pragma once #include afxwin.h class CUrlQueue { public: CUrlQueue() { InitializeCriticalSection(m_cs); } ~CUrlQueue() { DeleteCriticalSection(m_cs); } void Push(const CString strUrl) { EnterCriticalSection(m_cs); // 入队必须持锁 m_listUrl.AddTail(strUrl); LeaveCriticalSection(m_cs); } BOOL Pop(CString strUrl) { BOOL bRet FALSE; EnterCriticalSection(m_cs); if (!m_listUrl.IsEmpty()) { strUrl m_listUrl.GetHead(); m_listUrl.RemoveHead(); bRet TRUE; } LeaveCriticalSection(m_cs); return bRet; } int GetCount() { int nCount 0; EnterCriticalSection(m_cs); nCount m_listUrl.GetCount(); LeaveCriticalSection(m_cs); return nCount; } private: CListCString, CString m_listUrl; // MFC 链表容器 CRITICAL_SECTION m_cs; // 临界区对象 };三个方法的逻辑都不复杂但要注意两个细节。一是 Pop 的返回值语义队列为空时返回 FALSE调用方必须区分「队列暂时没任务」和「全部抓完」这个返回值是退出协议的一部分。二是持锁时间要短Push 和 Pop 内部只做链表操作绝不在锁里面发起网络请求或写文件否则一个慢操作会把所有线程卡在同一把锁上。这个封装的调用方根本接触不到锁加锁解锁全部收敛在三个方法里是我处理共享容器的一贯习惯。真出了问题排查范围也只在这一个文件里不用去几十个调用点找哪处忘了 LeaveCriticalSection。3.2 工作线程事件驱动的主循环与退出协议线程入口用 AfxBeginThread 拉起每个线程跑同一个 FetchLoop。这个循环的设计直接影响程序能不能干净退出是整份源码里最容易写错的地方。// SpiderCore.cpp - 启动工作线程 void CSpiderCore::Start(int nThreadCount) { m_bExit FALSE; // 手动重置事件有任务到达或需要唤醒线程时触发 m_hEventData CreateEvent(NULL, TRUE, FALSE, NULL); for (int i 0; i nThreadCount; i) { // CREATE_SUSPENDED 先挂起拿到指针后再恢复避免线程抢先跑 CWinThread* pThread AfxBeginThread(FetchThreadProc, this, THREAD_PRIORITY_NORMAL, 0, CREATE_SUSPENDED); m_pThreads.push_back(pThread); pThread-ResumeThread(); } } // 线程入口转发 UINT CSpiderCore::FetchThreadProc(LPVOID lpParam) { CSpiderCore* pCore (CSpiderCore*)lpParam; pCore-FetchLoop(); return 0; } // 线程主循环 void CSpiderCore::FetchLoop() { while (TRUE) { // 超时 3 秒是为了让线程周期性醒来检查退出标志 DWORD dwWait WaitForSingleObject(m_hEventData, 3000); // 退出条件收到退出指令并且队列已经清空 if (m_bExit m_queue.GetCount() 0) break; if (dwWait ! WAIT_OBJECT_0) continue; // 超时醒来回去继续检查 m_bExit CString strUrl; while (m_queue.Pop(strUrl)) // 一次把当前队列取完 { FetchAndSave(strUrl); // 请求 - 解析 - 落盘 } ResetEvent(m_hEventData); // 清掉任务信号 // 防止 ResetEvent 把刚 SetEvent 的新任务信号误清掉 // 清完后如果队列又有数据直接回到循环头部继续取 if (m_queue.GetCount() 0) continue; } }这段主循环有两个要点。第一退出条件是「m_bExit 为 TRUE 且队列为空」两者同时满足不能只看 m_bExit。如果队列里还有任务就退那批 URL 就丢了如果只看队列为空就退那线程可能永远不会结束。第二WaitForSingleObject 的 3 秒超时不是给抓取限速用的它存在的意义是让线程在事件永远不触发的情况下也能醒来检查退出标志——否则停止按钮按下后线程会一直阻塞在等待上进程退不干净。信号量这里有个常见的纠结ResetEvent 和 Push 里的 SetEvent 存在竞争可能把新任务信号误清掉导致任务在队列里躺到下一个 3 秒超时。代码里在 ResetEvent 之后又检查了一次队列计数并 continue把最坏情况压缩到几乎不发生。这是手动重置事件和自动重置事件之间权衡后的方案逻辑稍绕但比自动重置事件在 8 个线程下漏唤醒要可靠。3.3 请求封装CFetcher 与状态码校验抓取线程真正干活的地方是 FetchAndSave它先调 CFetcher 发请求拿到 HTML 原始字节再做编码转换和落盘。Fetcher 是整个工程里最独立的模块完全不知道多线程的存在这也是它能被替换的前提。// Fetcher.cpp - 基于 WinINet 的 HTTP 请求封装 #include afxinet.h #pragma comment(lib, wininet.lib) BOOL CFetcher::Fetch(const CString strUrl, CStringA strRaw, int nStatus, CString strContentType) { // 1. 创建会话句柄 HINTERNET hSession InternetOpen( _T(Mozilla/5.0 (Windows NT 10.0; Win64; x64) SpiderDemo/1.0), INTERNET_OPEN_TYPE_PRECONFIG, NULL, NULL, 0); if (!hSession) return FALSE; // 2. 打开 URL强制刷新且不写缓存 HINTERNET hFile InternetOpenUrl(hSession, strUrl, NULL, 0, INTERNET_FLAG_RELOAD | INTERNET_FLAG_NO_CACHE_WRITE, 0); if (!hFile) { InternetCloseHandle(hSession); return FALSE; } // 3. 取 HTTP 状态码非 200 直接判失败 DWORD dwStatus 0; DWORD dwSize sizeof(dwStatus); HttpQueryInfo(hFile, HTTP_QUERY_STATUS_CODE | HTTP_QUERY_FLAG_NUMBER, dwStatus, dwSize, NULL); nStatus (int)dwStatus; if (dwStatus ! 200) { InternetCloseHandle(hFile); InternetCloseHandle(hSession); return FALSE; } // 4. 取 Content-Type后面编码探测要用 TCHAR szType[128] { 0 }; DWORD dwTypeLen 128; HttpQueryInfo(hFile, HTTP_QUERY_CONTENT_TYPE, szType, dwTypeLen, NULL); strContentType szType; // 5. 分块读响应体 char szBuf[8192] { 0 }; DWORD dwRead 0; while (InternetReadFile(hFile, szBuf, sizeof(szBuf), dwRead) dwRead 0) { strRaw.Append(szBuf, dwRead); dwRead 0; // 每次循环清零防止最后一次残留旧值 } InternetCloseHandle(hFile); InternetCloseHandle(hSession); return TRUE; }分块读是这里最值得讲的地方。InternetReadFile 的行为是在一次调用里返回尽可能多的数据但不会超过缓冲区大小所以必须用循环读到 dwRead 为 0 为止。8192 字节的缓冲区是速度与内存的折中对普通 HTML 页面来说一次循环能装下大半个页面也不至于让栈压力过大。状态码校验是很多入门抓取翻车的重灾区。InternetOpenUrl 返回句柄只代表 TCP 连接建立成功不代表页面真的存在404 页面照样会返回一段正文。HttpQueryInfo 取到的 HTTP_QUERY_STATUS_CODE 才是服务器真实意图这里只放行 200其余状态全部走失败分支。默认情况下 InternetOpenUrl 会自动跟随 301/302 重定向所以最终拿到的状态码一般是落地页的不需要自己处理 Location 头。还有一个细节Fetcher 返回的是 CStringA 原始字节而不是直接转好的 CString。这是故意的——编码探测在 3.4 节做Fetcher 只负责把字节原样拿回来职责单一转换逻辑集中在 HtmlParser 里避免两个模块各转一遍导致行为不一致。3.4 字符集探测与统一落盘抓回来的 HTML 是字节流要正确显示必须先知道它的字符集。老站点大量是 GBK/GB2312新站多是 UTF-8如果统一按 UTF-8 解码GBK 页面转出来就是「锟斤拷」。正确顺序是先探测、再转换、最后统一以 UTF-8 落盘。// HtmlParser.cpp - 探测字符集 CString CHtmlParser::DetectCharset(const CStringA strHtml, const CString strContentType) { // 1. 优先看响应头 Content-Type 里的 charset int nPos strContentType.Find(_T(charset)); if (nPos 0) { CString strCharset strContentType.Mid(nPos 8); strCharset.Trim(); return strCharset; } // 2. 再看 HTML 前部的 meta charset... // 只取前 1024 字节做匹配避免整个页面转小写浪费内存 CStringA strHead strHtml.Left(1024); strHead.MakeLower(); CStringA strKey charset\; int nMetaPos strHead.Find(strKey); if (nMetaPos 0) { CStringA strValue strHead.Mid(nMetaPos strKey.GetLength(), 32); int nEnd strValue.Find(\); if (nEnd 0) return CString(strValue.Left(nEnd)); } return _T(utf-8); // 都找不到就按 UTF-8 兜底 } // 落盘原始编码 - 宽字符 - UTF-8 BOOL SaveHtml(const CString strFile, const CStringA strRaw, const CString strCharset) { CFile file; if (!file.Open(strFile, CFile::modeCreate | CFile::modeWrite)) return FALSE; // GBK 等单字节编码走 CP_ACPUTF-8 走 CP_UTF8 int nCodePage (_tcsicmp(strCharset, _T(gbk)) 0) ? CP_ACP : CP_UTF8; CStringW strW CA2W(strRaw, nCodePage); CStringA strOut CW2A(strW, CP_UTF8); // 统一转成 UTF-8 保存 file.Write(strOut.GetString(), strOut.GetLength()); file.Close(); return TRUE; }探测优先级要记牢响应头里的 Content-Type 最可信因为它是服务器在协议层面声明的HTML 里的 meta 标签是页面自己说的可能和实际编码不一致都找不到才用 UTF-8 兜底。这里简化成了只匹配 charset 的形式实际工程里还会遇到 http-equiv 写法比如meta http-equivContent-Type contenttext/html; charsetgb2312需要再加一个分支去解析 content 属性。为什么落盘前要绕一圈转成宽字符再转回 UTF-8因为页面原始字节可能是 GBK直接以 UTF-8 方式落盘文件会保存错误的字节序列先按正确代码页转成 UTF-16 的 CStringW再转成 UTF-8 输出相当于做了编码归一。这样不管源站是 GBK 还是 UTF-8本地所有文件都是同一种编码后面接解析、接入库都不用再关心每个文件自己的编码是什么。这段代码用的是 CA2W/CW2A 转换宏它会在栈上分配内存对几百 KB 的小页面没问题。如果你要抓几 MB 的大页面建议改用 MultiByteToWideChar 配合堆内存避免栈溢出。这也是 Fetcher 用 CStringA 传字节、把转换推迟到这个环节的好处——转换策略随时可以换不影响请求层。4. 多线程抓取避坑记录假死、乱码、被封的排查实录这一章是我实际跑这个工程时踩过的坑每条都是真实翻车现场。现象、原因、解决三段式写清楚不一定全记住但遇到类似症状时知道往哪个方向查。4.1 现象点开始没几秒整个程序假死抓取线程里直接调用 m_listResult.AddString 或者 SetDlgItemText 去刷新界面结果运行几秒后窗口完全卡死任务管理器看到 CPU 某个核接近 100%。原因MFC 的 CWnd 方法不是线程安全的。AddString 底层会向窗口发送消息多个工作线程同时操作同一个控件内部链表和 UI 线程自己的消息处理互相踩窗口消息队列就被锁死了。这不是偶发的线程越多越容易触发。解决抓取线程一律不碰 UI。抓完一条就 PostMessage 一条自定义消息由主窗口在自己的消息处理函数里刷新列表。我在 SpiderCore 里留了一个通知窗口句柄线程完事后这样投递// 工作线程中只投递消息不直接操作控件 ::PostMessage(pCore-m_hWndNotify, WM_SPIDER_RESULT, bOK ? 1 : 0, (LPARAM)new CString(strUrl));wParam 带成功失败标志lParam 带一个堆上 new 出来的 CString 指针UI 侧收到消息后取出来刷新日志并 delete。用 new 是因为 PostMessage 是异步的不能传栈上地址。从那以后我写多线程抓取第一行注释就是「线程内禁止碰任何控件」。4.2 现象抓到一堆 404 和拦截页面日志里却全标成功一批 URL 抓完成功数很高打开文件一看全是对应错误页的 HTML或者正文只有一小段提示脚本。原因只判断 InternetOpenUrl 返回了句柄没查 HTTP 状态码。服务器对 404、503 一样会正常建立连接并返回正文连接成功不代表内容有效。另一类是反爬返回 200但正文是验证或拦截脚本。解决两层校验。第一层是 3.3 节的 HttpQueryInfo 状态码判断非 200 全部算失败。第二层是正文兜底校验——统计 html 和 标签是否成对正文长度是否超过一个最小阈值比如少于 500 字节直接判无效。我一般把最小正文长度定义成一个常量每页抓完先过这道关卡再落盘能挡掉大部分「假成功」。4.3 现象页面抓到本地打开全是乱码文件确实存下来了但内容显示成「锟斤拷」「烫烫烫」之类的乱码或者英文字符正常、中文全花。原因页面编码和转换代码页不一致。老一点的内容站点大量是 GBK 编码如果 Fetcher 里统一按 UTF-8 解释字节GBK 的中文字节序列就会被错误解析转出来的宽字符自然全错。解决按 3.4 节的顺序做字符集探测先看响应头再看 meta最后才兜底。血泪经验是不要相信所有服务器都会在响应头里返回 charset相当一部分老站只写了 HTML meta还有一部分站 meta 写的是 utf-8实际内容是 GBK这种只能靠抓下来的字节做启发式判断。最笨但有效的兜底是先按 GBK 转一次看转换后是否出现大量无法映射的字符是的话再换 UTF-8 重转。4.4 现象线程数调大抓取速度没上去反而被断开连接从 4 线程调成 16 线程速度非但没翻倍还开始大量超时日志里陆续出现 403 和 429最后直接被目标服务器掐断连接。原因服务器有连接频率限制。16 个线程同时往同一个域名打请求相当于同一秒内发起十几次连接站点的防护逻辑直接判定异常流量。抓取是 IO 密集型不是计算密集型线程数超过网络瓶颈后只会加剧限流。解决加两个节流手段。一是每个线程抓完一条后随机 Sleep 一段可配置的时间比如 300 到 800 毫秒让请求波形打散而不是整齐地一排出去。二是按域名拆分队列同一个域名最多分配 2 个线程把并发压力分摊到不同站点。遇到 403/429 时做退避该域名 30 秒内不再发起请求。这两个手段加到工程里都不复杂但能把抓取成功率从 60% 拉回 95% 以上。4.5 现象点了停止界面响应了但进程一直退不干净停止按钮执行后窗口能关但进程在任务管理器里挂着一两个线程不消失或者进程退了但日志文件还没写完。原因m_bExit 置位后线程还阻塞在 InternetReadFile 等网络调用上没有设置超时阻塞可能长达几十秒或者对话框收到 WM_CLOSE 后直接退出没等线程收尾就销毁了资源和文件句柄。解决两件事都要做。一是给 InternetReadFile 设置操作超时常见做法是先用 InternetSetOption 设置 INTERNET_OPTION_RECEIVE_TIMEOUT 和 CONNECT_TIMEOUT把阻塞上限控制住让线程能及时醒来看到退出标志。二是在对话框的 OnDestroy 里遍历工作线程句柄对每个线程 WaitForSingleObject 一小段时间确认线程真正结束后再释放队列和事件对象。这里切忌用 TerminateThread 强杀它会跳过析构、不释放锁留下一个处于锁定状态的临界区下次启动必崩。5. 跑通与二次开发编译调试、断点续抓与入库改造5.1 编译与运行验证工程用 VS2015 及以上打开即可字符集建议设成 Unicode——代码里 CString 默认按宽字符处理转码逻辑依赖_T宏和 CA2W/CW2A多字节字符集下这些地方的行为会不一样。依赖只有系统库不需要安装任何第三方包。运行验证按四步走准备一个 urls.txt每行一个 URL填入对话框线程数先填 4点开始后观察日志逐条增长输出目录下生成同名 HTML 文件。重点检查三件事抓取条数和文件数一致、页面无乱码、停止后进程能正常退出。我每次会用本地起的测试服务先跑一遍确认逻辑没问题再对真实站点操作。5.2 三个实用的改造切入点第一个是 BFS 扩散抓取在 HtmlParser 里提取a href链接通过 Push 再入队抓取就会从单页变成整站爬取。注意在 Push 前要加去重集合我用 CMapStringToPtr 存已抓过的 URL8 个线程并发时同一链接一旦重复入队访问量就放大 8 倍。第二个是结果入库把落盘文件改成写 SQLite。sqlite3 是 C API封装一个 CSqliteSaver每抓完一条做一次 INSERT表的字段至少要覆盖 url、title、http_status、fetched_at、content 这五列。多条线程同时写 SQLite 时建议先写进一个内存队列由单条线程批量提交减少事务次数和锁冲突。第三个是线程模型升级把 AfxBeginThread 换成 std::thread 加 condition_variable让 SpiderCore 不依赖 MFC抽成纯 C 类之后移植到服务端或命令行工具都不用改业务逻辑。改造时保持 CUrlQueue 接口不变内部把 CRITICAL_SECTION 换成 std::mutex 即可调用方完全无感。5.3 改造后的验证清单改动后不要直接上 16 线程按照「单线程 → 4 线程 → 8 线程」逐级验证。每个阶段确认三件事抓取结果集合和单线程版本一致、内存不随时间线性增长、任务管理器里句柄数和 GDI 对象数稳定。特别是句柄数——WinINet 的 HINTERNET 句柄忘记关闭是这类工程最常见的资源泄漏点跑 1000 个 URL 前后对比一次就心里有数。从那以后我每次接手类似的 C 抓取工程都会强制先跑一遍「开始 → 停止 → 退出 → 查句柄」四个动作确认线程能干净收场再开始动业务逻辑。这套习惯帮我挡掉了好几次线程泄漏和假死问题。希望帮到你。本文还有配套的精品资源点击获取