简介:HTTP文件上传是客户端与服务器进行数据交换的常见方式,其核心在于通过POST请求将文件数据编码后传输至服务端。在桌面应用开发中,实现这一功能需要处理网络通信、数据编码和用户交互等多个环节。对于基于MFC框架的Windows桌面程序,WinHTTP库提供了稳定高效的原生网络通信能力,特别适合用于实现文件上传等客户端-服务器交互场景。通过构造multipart/form-data格式的请求体,可以将文件内容与表单数据一并发送,满足大多数Web服务的接口要求。本文以MFC对话框应用为例,详细解析如何利用WinHTTP API实现完整的文件上传功能,涵盖界面设计、网络库选型、请求构造、进度反馈及错误处理等关键技术点,为遗留MFC项目的现代化改造提供可直接复用的工程实践方案。
1. 项目缘起:为什么要在MFC里折腾文件上传?
最近接手了一个老项目的维护任务,核心需求是在一个基于MFC(Microsoft Foundation Classes)的桌面客户端里,增加一个文件上传到远程服务器的功能。这听起来像是Web开发里的基础操作,但在Win32桌面程序,特别是MFC这个“老古董”框架里,要实现一个带界面的、支持HTTP/HTTPS的文件上传功能,还真得花点心思。用户需要一个简单的对话框:能选择本地文件、能填写服务器地址(支持HTTP和HTTPS)、能点个按钮上传,最好还能看到进度。这不仅仅是调个库那么简单,它涉及到MFC的界面交互、WinINet或WinHTTP的网络通信、以及文件流处理等多个环节的串联。
我猜你点开这篇文章,可能也面临着类似的场景:一个遗留的MFC工程需要现代化改造,或者就是一个新的桌面工具需要集成简单的文件上报功能。网上关于MFC的现代资料不多,成体系的文件上传示例更少,很多代码片段要么过于简单只演示了核心API调用,要么耦合了特定业务逻辑难以复用。所以,我把自己从零搭建这个功能模块的过程,包括界面设计、网络库选型、核心代码实现、以及踩过的那些坑,都整理出来。目标很明确:给你一份能直接“抄作业”的、结构清晰的实现方案,让你在MFC框架下也能轻松搞定文件上传。
2. 核心组件选型与设计思路
在动手写代码之前,得先把架构想清楚。一个完整的文件上传功能,可以拆解为三个核心部分:用户交互界面、文件读取与编码、网络请求发送。在MFC的世界里,每个部分都有一些经典的选择和需要特别注意的细节。
2.1 界面设计:用对话框承载一切
对于这种功能相对独立、交互明确的模块,使用MFC的对话框(CDialogEx)是最合适的选择。我们创建一个新的对话框资源,然后在上面摆放需要的控件:
- 文件选择:一个
CEdit文本框用来显示文件路径,旁边配一个CButton按钮,点击后弹出文件选择对话框(CFileDialog)。这是最直观的方式。 - 服务器地址配置:一个
CEdit文本框,让用户输入完整的URL,例如http://example.com/upload或https://secure.com/api/upload。为了更友好,可以增加一个CComboBox下拉框,预置http://和https://协议头,但考虑到用户可能输入带端口的复杂地址,一个简单的编辑框反而更灵活。 - 上传按钮与状态显示:一个
CButton控件触发上传动作。一个CStatic或CEdit(设置为只读)用来显示上传状态,如“准备中”、“上传中xx%”、“成功”或“失败原因”。如果需要更直观的进度反馈,可以加入一个CProgressCtrl进度条控件。
界面的核心逻辑在对话框类的OnInitDialog中初始化,在按钮的BN_CLICKED消息响应函数中触发文件选择和上传操作。这里有一个细节:文件选择对话框最好设置过滤器,只允许选择需要的文件类型,比如“All Files (*.*)|*.*|Text files (*.txt)|*.txt”。
2.2 网络库抉择:WinINet vs WinHTTP
这是最关键的技术选型。MFC环境下,我们通常有两个原生的Windows网络API选择:WinINet和WinHTTP。
WinINet是一个更老、更偏向于模拟浏览器交互的库。它高级、易用,内置了对HTTP协议、Cookie、缓存等功能的支持,CInternetSession、CHttpConnection这些MFC封装类让发起请求变得简单。但是,它的一个主要设计目标是用于交互式客户端(如浏览器),在某些服务端通信的场景下可能不够稳定或灵活,而且官方文档也暗示对于服务端场景,更推荐使用WinHTTP。
WinHTTP是微软后来推出的、更侧重于服务端通信场景的库。它提供了更纯净、更底层的HTTP协议栈,没有WinINet那些针对浏览器行为的“包袱”,因此在自动化脚本、服务通信等场景下更稳定、性能也更好。WinHTTP也支持HTTPS(SSL/TLS)。
对于我们的文件上传功能——一个桌面客户端向服务器发送数据——我强烈推荐使用WinHTTP。理由如下:
- 稳定性:WinHTTP为服务端通信优化,减少了不必要的开销和潜在的不稳定因素。
- 清晰性:API设计更直接,对于“构建请求-发送请求-读取响应”这个流程的控制更精细。
- 官方推荐:在MSDN文档中,对于非交互式的HTTP客户端操作,微软的建议是使用WinHTTP。
因此,我们的实现将基于WinHTTP API。虽然MFC没有为WinHTTP提供像CInternetSession那样的封装类,但它的C语言API用起来也并不复杂,我们可以自己封装关键步骤。
2.3 文件处理与POST请求体构造
上传文件通常使用HTTP POST请求,内容类型(Content-Type)为multipart/form-data。这是一种将表单数据和文件数据编码在同一个请求体中的格式,用特定的“边界符”(boundary)来分隔不同部分。
我们需要在代码中动态构造这样一个请求体:
- 生成一个唯一的边界字符串,例如
“----WebKitFormBoundary7MA4YWxkTrZu0gW”。 - 对于每个要上传的文件,构造一个部分(part),包含描述头和信息头。
--{boundary} Content-Disposition: form-data; name="file"; filename="example.txt" Content-Type: text/plain [这里是文件的实际二进制内容] - 在请求体的最后加上结束边界:
--{boundary}--。
这意味着我们需要以二进制模式读取本地文件,将文件内容嵌入到这个格式化的文本结构中,然后一并发送。这里要注意编码和换行符(\r\n)的使用必须符合HTTP规范。
3. 基于WinHTTP实现文件上传的核心代码
理论说完了,我们来看具体怎么用WinHTTP API一步步实现上传。我会把关键代码拆解开,并解释每一步的作用。
3.1 初始化WinHTTP会话与连接
首先,我们需要打开一个WinHTTP会话,这是一个基础句柄,后续操作都依赖于它。
#include <windows.h> #include <winhttp.h> #pragma comment(lib, "winhttp.lib") // 在你的上传函数中 HINTERNET hSession = NULL; HINTERNET hConnect = NULL; HINTERNET hRequest = NULL; BOOL bResults = FALSE; // 1. 初始化WinHTTP会话 hSession = WinHttpOpen(L"A MFC Upload Client/1.0", WINHTTP_ACCESS_TYPE_DEFAULT_PROXY, WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0); if (!hSession) { AfxMessageBox(_T("WinHttpOpen failed.")); return; }WinHttpOpen的第一个参数是用户代理字符串,可以按需修改。这里我们使用默认的代理设置。
接下来,我们需要从用户输入的URL中解析出服务器主机名、端口和路径。WinHTTP提供了WinHttpCrackUrl函数来完成这个解析工作。
CString strServerURL; // 假设从界面编辑框获取,如 "https://example.com/upload" URL_COMPONENTS urlComp; ZeroMemory(&urlComp, sizeof(urlComp)); urlComp.dwStructSize = sizeof(urlComp); // 设置需要解析的字段长度,-1表示自动计算 urlComp.dwSchemeLength = (DWORD)-1; urlComp.dwHostNameLength = (DWORD)-1; urlComp.dwUrlPathLength = (DWORD)-1; urlComp.dwExtraInfoLength = (DWORD)-1; if (!WinHttpCrackUrl(strServerURL, strServerURL.GetLength(), 0, &urlComp)) { AfxMessageBox(_T("Invalid URL.")); WinHttpCloseHandle(hSession); return; } // 根据解析结果建立连接 CString strHostName(urlComp.lpszHostName, urlComp.dwHostNameLength); INTERNET_PORT nPort = urlComp.nPort; if (nPort == 0) { // 如果URL中没有指定端口,则根据协议使用默认端口 nPort = (urlComp.nScheme == INTERNET_SCHEME_HTTPS) ? INTERNET_DEFAULT_HTTPS_PORT : INTERNET_DEFAULT_HTTP_PORT; } hConnect = WinHttpConnect(hSession, strHostName, nPort, 0); if (!hConnect) { AfxMessageBox(_T("WinHttpConnect failed.")); WinHttpCloseHandle(hSession); return; }3.2 创建HTTP请求并构造Multipart请求体
建立连接后,就可以创建具体的HTTP请求了。我们使用POST方法。
CString strPath(urlComp.lpszUrlPath, urlComp.dwUrlPathLength); // 注意:lpszExtraInfo包含了查询参数(如?key=value),如果需要也要拼接到路径 if (urlComp.dwExtraInfoLength > 0) { strPath += CString(urlComp.lpszExtraInfo, urlComp.dwExtraInfoLength); } hRequest = WinHttpOpenRequest(hConnect, L"POST", strPath, NULL, WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES, (urlComp.nScheme == INTERNET_SCHEME_HTTPS) ? WINHTTP_FLAG_SECURE : 0); if (!hRequest) { AfxMessageBox(_T("WinHttpOpenRequest failed.")); // 清理资源... return; }这里的关键是最后一个参数,如果URL是HTTPS协议(INTERNET_SCHEME_HTTPS),必须添加WINHTTP_FLAG_SECURE标志,否则SSL连接会失败。
接下来是最复杂的一步:构造multipart/form-data请求体并发送。我们需要将文件内容读入内存,并按照格式拼接好。
CString strFilePath; // 从文件选择对话框获取的路径 CStringA strBoundary = "----WebKitFormBoundary7MA4YWxkTrZu0gW"; CStringA strContentType; strContentType.Format("multipart/form-data; boundary=%s", strBoundary); // 读取文件内容到字节数组 CFile file; if (!file.Open(strFilePath, CFile::modeRead | CFile::typeBinary)) { AfxMessageBox(_T("Cannot open file.")); // 清理资源... return; } DWORD dwFileSize = (DWORD)file.GetLength(); BYTE* pFileData = new BYTE[dwFileSize]; file.Read(pFileData, dwFileSize); file.Close(); // 构造请求体 // 首先计算整个请求体的大小(这是一个简化示例,假设只上传一个文件,字段名为"file") CStringA strPartHeader; CStringA strPartFooter = "\r\n"; CStringA strEndBoundary; strPartHeader.Format("--%s\r\nContent-Disposition: form-data; name=\"file\"; filename=\"%s\"\r\nContent-Type: application/octet-stream\r\n\r\n", strBoundary, CStringA(CT2CA(PathFindFileName(strFilePath)))); strEndBoundary.Format("\r\n--%s--\r\n", strBoundary); DWORD dwHeaderLen = strPartHeader.GetLength(); DWORD dwFooterLen = strPartFooter.GetLength(); DWORD dwEndBoundaryLen = strEndBoundary.GetLength(); DWORD dwTotalBodySize = dwHeaderLen + dwFileSize + dwEndBoundaryLen; BYTE* pRequestBody = new BYTE[dwTotalBodySize]; BYTE* pCurrent = pRequestBody; // 拷贝部件头 memcpy(pCurrent, strPartHeader.GetString(), dwHeaderLen); pCurrent += dwHeaderLen; // 拷贝文件数据 memcpy(pCurrent, pFileData, dwFileSize); pCurrent += dwFileSize; // 拷贝结束边界 memcpy(pCurrent, strEndBoundary.GetString(), dwEndBoundaryLen); delete[] pFileData; // 释放文件数据内存 // 设置请求头 CStringA strHeaders; strHeaders.Format("Content-Type: %s\r\n", strContentType); bResults = WinHttpAddRequestHeaders(hRequest, strHeaders, strHeaders.GetLength(), WINHTTP_ADDREQ_FLAG_ADD); if (!bResults) { AfxMessageBox(_T("WinHttpAddRequestHeaders failed.")); delete[] pRequestBody; // 清理资源... return; }注意:上面的代码为了清晰,将整个请求体一次性构造在内存中。如果上传的文件非常大(比如几百MB),这会消耗大量内存。在实际项目中,对于大文件,应该使用
WinHttpWriteData流式发送,即分块读取文件并分块写入请求体。为了简化示例,这里采用了一次性构造的方式,你在实际应用时需要根据文件大小评估策略。
3.3 发送请求、接收响应与资源清理
请求体准备好后,就可以发送请求了。
// 发送请求 bResults = WinHttpSendRequest(hRequest, WINHTTP_NO_ADDITIONAL_HEADERS, 0, pRequestBody, dwTotalBodySize, dwTotalBodySize, 0); if (!bResults) { AfxMessageBox(_T("WinHttpSendRequest failed.")); } else { // 等待服务器响应 bResults = WinHttpReceiveResponse(hRequest, NULL); if (bResults) { // 读取响应状态码 DWORD dwStatusCode = 0; DWORD dwSize = sizeof(dwStatusCode); WinHttpQueryHeaders(hRequest, WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER, WINHTTP_HEADER_NAME_BY_INDEX, &dwStatusCode, &dwSize, WINHTTP_NO_HEADER_INDEX); CString strStatus; strStatus.Format(_T("Server responded with status: %d"), dwStatusCode); AfxMessageBox(strStatus); // 可以继续读取响应体(如果有的话) // ... } else { AfxMessageBox(_T("WinHttpReceiveResponse failed.")); } } // 无论成功失败,都要清理内存和句柄 delete[] pRequestBody; if (hRequest) WinHttpCloseHandle(hRequest); if (hConnect) WinHttpCloseHandle(hConnect); if (hSession) WinHttpCloseHandle(hSession);发送请求后,我们通过WinHttpQueryHeaders查询状态码,根据状态码(如200表示成功,4xx/5xx表示错误)来更新UI,告知用户上传结果。
4. 界面与逻辑的整合及进度反馈
核心网络通信代码完成后,我们需要把它整合到MFC的对话框界面中,并实现进度反馈,让用户有感知。
4.1 在对话框类中整合上传逻辑
首先,在你的对话框类(例如CUploadDlg)中,为“上传”按钮添加消息处理函数(ON_BN_CLICKED)。
void CUploadDlg::OnBnClickedButtonUpload() { // 1. 从控件获取用户输入 CString strFile, strURL; GetDlgItemText(IDC_EDIT_FILEPATH, strFile); GetDlgItemText(IDC_EDIT_SERVERURL, strURL); if (strFile.IsEmpty() || strURL.IsEmpty()) { AfxMessageBox(_T("Please select a file and enter server URL.")); return; } // 2. 更新UI状态,例如禁用上传按钮,显示“上传中...” GetDlgItem(IDC_BUTTON_UPLOAD)->EnableWindow(FALSE); SetDlgItemText(IDC_STATIC_STATUS, _T("Uploading...")); // 3. 为了避免界面卡死,将上传操作放在一个单独的线程中执行。 // 这里为了示例清晰,我们暂时在主线程执行(会卡住界面)。 // 实际强烈建议使用工作线程(Worker Thread)。 BOOL bSuccess = DoUploadFile(strFile, strURL); // 调用我们前面编写的上传函数 // 4. 根据上传结果更新UI GetDlgItem(IDC_BUTTON_UPLOAD)->EnableWindow(TRUE); if (bSuccess) { SetDlgItemText(IDC_STATIC_STATUS, _T("Upload Successful!")); } else { SetDlgItemText(IDC_STATIC_STATUS, _T("Upload Failed.")); } }4.2 实现进度反馈的挑战与方案
在文件上传过程中显示进度条是一个提升用户体验的好功能,但在WinHTTP中实现它需要一些技巧。WinHTTP的WinHttpSendRequest和WinHttpWriteData函数本身不提供进度回调。我们需要模拟进度。
方案一:对于“一次性发送”模式(小文件)如果我们像前面示例一样,将整个请求体在内存中构造好然后一次性发送,我们只能知道“开始发送”和“发送完成”两个状态。此时,进度条可以设置为“忙碌”状态(Marquee风格),或者简单地显示一个动画图标,因为无法得知精确的发送百分比。
方案二:对于“流式发送”模式(大文件,推荐)这才是实现真实进度反馈的正确方式。步骤是:
- 使用
WinHttpSendRequest发送请求头,但将请求体长度设为0(WINHTTP_IGNORE_REQUEST_TOTAL_LENGTH),并设置WINHTTP_FLAG_ASYNC标志进行异步操作(这涉及到更复杂的回调函数)。 - 然后,分块读取文件(例如每次64KB),通过
WinHttpWriteData函数将数据块写入请求体。 - 在每次成功调用
WinHttpWriteData后,可以根据已写入的数据量除以文件总大小,计算出当前进度,并通过线程消息(如PostMessage)通知主界面更新进度条。
由于异步操作和线程间通信涉及较多MFC和Win32编程细节,代码量会大幅增加。一个更简单的折中方案是:仍然在工作线程中同步发送,但将文件分块,每发送完一块,计算并更新一次进度。虽然主界面在传输期间仍然无法响应(因为工作线程在阻塞式发送),但至少进度条会逐步前进。这需要修改DoUploadFile函数,使用循环调用WinHttpWriteData。
// 伪代码,展示流式发送和进度更新思路 BOOL CUploadDlg::DoUploadFileStream(const CString& strFile, const CString& strURL) { // ... 初始化WinHTTP会话、连接、请求(使用WINHTTP_IGNORE_REQUEST_TOTAL_LENGTH) ... // 发送请求头 WinHttpSendRequest(hRequest, ...); CFile file; file.Open(strFile, CFile::modeRead | CFile::typeBinary); const DWORD dwBufferSize = 64 * 1024; // 64KB BYTE* pBuffer = new BYTE[dwBufferSize]; DWORD dwTotalRead = 0; DWORD dwFileSize = (DWORD)file.GetLength(); while (dwTotalRead < dwFileSize) { DWORD dwToRead = min(dwBufferSize, dwFileSize - dwTotalRead); DWORD dwRead = 0; file.Read(pBuffer, dwToRead); dwTotalRead += dwRead; // 写入这一块数据到请求体 if (!WinHttpWriteData(hRequest, pBuffer, dwRead, NULL)) { // 错误处理 break; } // 计算并更新进度 (0-100) int nProgress = (int)((dwTotalRead * 100) / dwFileSize); // 需要通过线程安全的方式通知主窗口更新进度条,例如PostMessage ::PostMessage(this->m_hWnd, WM_USER_UPDATE_PROGRESS, nProgress, 0); } file.Close(); delete[] pBuffer; // ... 接收响应、清理资源 ... }然后在对话框类中处理WM_USER_UPDATE_PROGRESS消息,更新进度条控件。
5. 实战中遇到的坑与解决方案
在实际编码和测试过程中,我遇到了几个典型问题,这里列出来帮你避坑。
5.1 HTTPS证书验证失败问题
当你尝试连接一个使用HTTPS的服务器时,可能会遇到证书错误,导致WinHttpSendRequest或WinHttpReceiveResponse失败。WinHTTP默认会验证服务器证书。对于测试环境或自签名证书,你可能需要放宽验证。
解决方案:在调用WinHttpOpenRequest之后,WinHttpSendRequest之前,设置请求选项以忽略证书错误(仅限测试环境!生产环境必须严格验证证书)。
DWORD dwOption = SECURITY_FLAG_IGNORE_UNKNOWN_CA | SECURITY_FLAG_IGNORE_CERT_DATE_INVALID | SECURITY_FLAG_IGNORE_CERT_CN_INVALID | SECURITY_FLAG_IGNORE_CERT_WRONG_USAGE; WinHttpSetOption(hRequest, WINHTTP_OPTION_SECURITY_FLAGS, &dwOption, sizeof(dwOption));这段代码会忽略未知证书颁发机构、过期证书、证书域名不匹配等问题。正式上线的软件绝不能这样做,否则会面临中间人攻击风险。生产环境应确保服务器使用有效的、受信任的CA签发的证书。
5.2 请求超时与网络异常处理
网络操作充满了不确定性。必须设置合理的超时,并做好异常处理。
// 在创建请求后,可以设置各种超时(单位毫秒) int nTimeout = 30000; // 30秒 WinHttpSetTimeouts(hSession, nTimeout, nTimeout, nTimeout, nTimeout);这个WinHttpSetTimeouts函数设置了解析、连接、发送和接收的超时时间。同时,所有WinHTTP API的调用都应该检查返回值(bResults),一旦失败,进入错误处理流程,关闭所有已打开的句柄,并给用户明确的错误提示(例如“连接服务器超时”、“发送数据失败”)。资源清理的代码(WinHttpCloseHandle)必须放在if分支和finally块中,确保任何路径下句柄都能被正确关闭,防止内存泄漏。
5.3 内存管理与句柄泄漏
WinHTTP使用的是句柄(HINTERNET)体系。WinHttpOpen、WinHttpConnect、WinHttpOpenRequest返回的句柄,在使用完毕后必须用WinHttpCloseHandle关闭,且顺序应该是先关闭请求句柄(hRequest),再关闭连接句柄(hConnect),最后关闭会话句柄(hSession),类似于栈的顺序。
内存管理方面,我们手动申请了pFileData和pRequestBody内存块,在函数所有退出路径(正常返回和错误返回)前,都必须用delete[]释放它们。一个好的习惯是,在函数开头就将这些指针初始化为NULL,在清理时检查是否为NULL再释放。
5.4 界面卡顿与多线程
正如前面提到的,网络请求是阻塞式I/O操作,如果放在主UI线程中执行,在上传大文件时,界面会完全卡住,用户体验极差。正确的做法是使用工作线程。
MFC中可以使用AfxBeginThread创建一个工作线程,将上传任务(DoUploadFile)放在该线程中执行。主线程(UI线程)通过向对话框发送消息(PostMessage)来更新进度和状态。这需要你定义自定义消息(如WM_UPLOAD_PROGRESS、WM_UPLOAD_FINISHED),并在对话框的消息映射(ON_MESSAGE)中处理它们。这是MFC桌面程序开发中一个经典的多线程UI更新模式,虽然代码结构会变复杂,但对于提供流畅的用户体验是必须的。
6. 功能扩展与优化方向
实现基础功能后,还可以根据实际需求进行增强:
1. 多文件上传与队列管理当前的逻辑是单文件上传。你可以扩展文件选择部分,使用CFileDialog的多选模式(OFN_ALLOWMULTISELECT),获取一个文件路径列表。然后,创建一个上传队列,在工作线程中依次处理每个文件,并为整个队列提供总进度显示。
2. 断点续传对于超大文件,断点续传非常有用。这需要服务器也支持相应的协议(通常是在请求头中设置Range字段)。客户端需要在上传前先查询服务器上该文件已存在的部分(通过HEAD请求),然后从断点处开始发送剩余数据。实现起来比较复杂,涉及到本地记录上传状态和更精细的网络控制。
3. 更友好的配置管理将服务器地址、超时时间等配置保存到注册表或INI文件中,下次启动时自动加载,避免用户每次都要手动输入。
4. 使用更现代的库如果你不局限于原生WinAPI,可以考虑在MFC项目中集成第三方HTTP客户端库,如libcurl。libcurl功能极其强大,对multipart/form-data、HTTPS、代理、Cookie等支持得更好,且是跨平台的。集成libcurl需要额外配置项目依赖,但可能会让网络部分的代码更简洁、更健壮。这对于计划长期维护或功能需求复杂的项目,是一个值得考虑的选项。
5. 详细的日志记录在DoUploadFile函数的关键步骤(开始连接、发送数据、收到响应、发生错误)添加日志输出,记录到文件或调试窗口。这在排查线上用户遇到的问题时,是 invaluable 的信息来源。你可以简单使用OutputDebugString,或者更正式地写到一个日志文件中。
把这个功能集成到你的MFC项目里,本质上是一次对Windows网络编程和MFC对话框编程的实践。从简单的界面布局,到选择WinHTTP作为通信基石,再到亲手构造HTTP multipart请求体,最后处理好线程、进度和错误,每一步都踩在桌面应用开发的实地上。当你看到自己写的程序成功把文件推送到远端服务器时,那种对底层流程的掌控感,是直接用现成网络库所不能比的。当然,如果后续需求变得非常复杂,引入libcurl这类专业库会是更高效的选择,但通过WinHTTP实现的这个过程,无疑让你对HTTP文件上传的里里外外都有了透彻的理解。
本文还有配套的精品资源,点击获取