郑重声明:
本文仅供学习使用,禁止用于非法用途,否则后果自负,如有侵权,烦请告知删除,谢谢合作!
开篇明义
本文针对自主开发的抢票脚本在抢票过程中常常遇到的请求无效等问题,简单分析了 12306 网站的前端加密算法,更准确的说,是探究 RAIL_DEVICEID
的生成过程.
因为该 cookie 值是抢票请求的核心基础,没有它将无法正确发送请求,或者一段时间后就会到期失效需要重新获取,或者明明更改了浏览器用户代理(navigator.userAgent)标识却还是被限制访问…
因为它并不是真正的客户端标识,只是迷惑性战术,浏览器唯一标识其实是 RAIL_OkLJUJ
而它却被 12306 网站设计者故意没有添加到 cookie ,因此造成了很强的欺骗性,编程真的是一门艺术!
你以为你的爬虫已经可以正常模仿浏览器,殊不知,只要没搞懂谁才是真正的浏览器标识,那么再怎么换马甲也难逃造假事实.
上图展示了 RAIL_OkLJUJ
的存在位置,可能是为了兼容市面上绝大数浏览器,也可能是为了联合各种前端缓存技术作为特征码,总是除了 cookie 之外,RAIL_OkLJUJ
存在于 Local Storage
, Session Storage
, IndexedDB
和Web SQL
等.
值得注意的是,cookie 中故意没有设置 RAIL_OkLJUJ
,如果清空全部缓存后再次刷新网页,你就会发现 RAIL_DEVICEID
已经发生变化了而 RAIL_OkLJUJ
依旧没变!
下面简单验证一下说明谁才是真正的浏览器唯一标识:
- step 1 : 复制当前获取到的
RAIL_DEVICEID
和RAIL_OkLJUJ
的值
打开控制台(Console),通过 js 代码方式取出本地存储(localStorage) 的值:
localStorage.getItem("RAIL_DEVICEID");
localStorage.getItem("RAIL_OkLJUJ");
控制台会立即返回该值,接下来需要手动复制到其他地方等待和第二次结果作比较.
但是程序员总是喜欢能偷懒就偷懒,手动复制也懒得复制怎么办?
当然,继续使用js 代码复制了啊!
copy('雪之梦技术驿站欢迎您的访问,https://snowdreams1006.cn');
比如这句代码就会把文本'雪之梦技术驿站欢迎您的访问,https://snowdreams1006.cn'
复制到剪贴板,接下来选择文本编辑器右键粘贴就能看到效果啦!
所以改造一下代码就能复制第一次访问 12306 网站获取到的 RAIL_DEVICEID
和 RAIL_OkLJUJ
的值.
copy("RAIL_DEVICEID:::"+localStorage.getItem("RAIL_DEVICEID"));
// RAIL_DEVICEID:::E5BDkKrPkZ6nuZruqUj9-3lUG1LBM7t9aTDbZwFSdrboaFG6odrWZ9yuphnas4Jwq5E_FXIwwqlRoSXFbJULUiBNwNGt61Ow6Zv0GFXRABipaeDJJ0Ub7G2g_B_aGwMF5DNZ5KJR4eWVl-P3zSHGKbczLB3WN0z-
copy("RAIL_OkLJUJ:::"+localStorage.getItem("RAIL_OkLJUJ"));
// RAIL_OkLJUJ:::FGFOJ75VdD8dQc2yh3yTJf2RBWES6uGI
- step 2 : 等待 5 min 后再次获取
RAIL_DEVICEID
和RAIL_OkLJUJ
的值
copy("RAIL_DEVICEID:::"+localStorage.getItem("RAIL_DEVICEID"));
// RAIL_DEVICEID:::VUye37EEUdGHgrpJGo9J95hWMNSIUFPeYBjabDgCiYJbQIr53iVzIPQJwcLhbijL4OyPVGmzolsVEK8Pw7_DG_oPrUDpfbnRe7HvMWMJvU2MAbk-7EwNEePAlpnVb9QVZz4dtOUSCRVbS2zlwgS0xe2BOThpR9oy
copy("RAIL_OkLJUJ:::"+localStorage.getItem("RAIL_OkLJUJ"));
// RAIL_OkLJUJ:::FGFOJ75VdD8dQc2yh3yTJf2RBWES6uGI
或者清空网站 cookie 后再次刷新当前网页,总之就是想办法触发浏览器再次运行相关逻辑重新生成
RAIL_DEVICEID
和RAIL_OkLJUJ
.
- step 3 : 对比第一次和第二次获取到的
RAIL_DEVICEID
和RAIL_OkLJUJ
的值
RAIL_DEVICEID:::E5BDkKrPkZ6nuZruqUj9-3lUG1LBM7t9aTDbZwFSdrboaFG6odrWZ9yuphnas4Jwq5E_FXIwwqlRoSXFbJULUiBNwNGt61Ow6Zv0GFXRABipaeDJJ0Ub7G2g_B_aGwMF5DNZ5KJR4eWVl-P3zSHGKbczLB3WN0z-
RAIL_OkLJUJ:::FGFOJ75VdD8dQc2yh3yTJf2RBWES6uGI
RAIL_DEVICEID:::VUye37EEUdGHgrpJGo9J95hWMNSIUFPeYBjabDgCiYJbQIr53iVzIPQJwcLhbijL4OyPVGmzolsVEK8Pw7_DG_oPrUDpfbnRe7HvMWMJvU2MAbk-7EwNEePAlpnVb9QVZz4dtOUSCRVbS2zlwgS0xe2BOThpR9oy
RAIL_OkLJUJ:::FGFOJ75VdD8dQc2yh3yTJf2RBWES6uGI
显而易见,肉眼直接就能看出两次请求时 RAIL_OkLJUJ
的值并没有变化而 RAIL_DEVICEID
的值很大可能会发生改变.
因此,RAIL_DEVICEID
应该并不是浏览器唯一标识,而 RAIL_OkLJUJ
才是真正的唯一标识!
本文并不适合全部读者,如果你属于以下情况之一,那么本文对你绝对帮助甚多,否则对你来说只能算是浪费生命.
- 适合对自主抢票或者脚本抢票有需求的天涯游子
- 适合拥有一定 web 前端开发相关知识的开发者
- 适合耐得住寂寞能够独自研究加密算法的孤独人
最后的核心前提是有网,当然WiFi更佳,否则流量真的吃不消啊!
故事背景
独在异乡为异客 每逢佳节要抢票
手动自动一起上 时常掉线心好伤
动手实践出真理 原来身份是唯一
想要封你没商量 只能动手来伪装
加密请求在前端 后端返还控制权
还原算法改身份 稳定抢票不担心
多种途径齐上阵 车票速速快现身
不知道你是否遭遇过一票难求的困境,尽管网络上关于第三方工具的加速包是否加速有过辟谣,但是每逢节假日总是会遇到抢不到车票的问题,大部分人还是会选择买个心理安慰吧!
目前为止,12306 官方线上售票渠道仅仅包括 12306 网站以及手机 app 客户端,因此市面上流行的第三方抢票软件均为非正常途径,而这些第三方渠道中最简单的实现方式应该就算是爬虫技术了.
不论是网页端还是手机端,统统称为客户端,客户端的作用仅仅是传声筒,真正负责执行命令的人就是服务端.
当你提交购票需求时,客户端会把这些车票信息一起打包发送给服务端,如果服务端有票的话,那么有可能就会返回给客户端成功信息,恭喜你订票成功.
但是尽管有票也不一定会给你,唯一确定的是无票一定会失败,总之不管结果如何服务端和客户端总是按照既定的约定协议在默默交流着…
尽管官方渠道最可靠也最准确,可官方也还是没能给你买到车票啊!
所以想要抢票还是得亲自动手,不能完全依靠官方,这里就诞生了爬虫技术来冒充客户端,想要成功骗过服务端就要先了解真正的客户端到底有哪些特征?
由于本文篇幅有限,暂时不做关于抢票方面的相关论述,直奔重点,讲解 RAIL_DEVICEID
的请求过程,带你一步一步还原 12306 网站的前端加密算法的实现逻辑!
效果预览
在浏览器控制台运行 chromeHelper.prototype.encryptedFingerPrintInfo()
方法时会计算真实浏览器信息,如果发现计算结果中的 value
值和真正请求 https://kyfw.12306.cn/otn/HttpZF/logdevice 的 hashcode
值相同,那么恭喜您,说明 12306 相关算法还没更新,如果不相同估计算法又稍微调整了!
事实证明,12306 算法虽然在变但都是小打小闹,根本没有伤筋动骨,所以自己动手改改又能满血复活了哟!
{
"key": "&FMQw=0&q4f3=zh-CN&VPIf=1&custID=133&VEek=unknown&dzuS=0&yD16=0&EOQP=c227b88b01f5c513710d4b9f16a5ce52&jp76=52d67b2a5aa5e031084733d5006cc664&hAqN=MacIntel&platform=WEB&ks0Q=d22ca0b81584fbea62237b14bd04c866&TeRS=777x1280&tOHY=24xx800x1280&Fvje=i1l1o1s1&q5aJ=-8&wNLf=99115dfb07133750ba677d055874de87&0aew=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.87 Safari/537.36&E3gR=9f7fa43e794048f6193187756181b3b9",
"value": "owRJc8M4EkFMvcTkzibRFJoDSkUKCx6N9ictZIJLIeY"
}
- step 1 : 使用 Chrome 浏览器打开 12306 网站并清空该站点全部缓存数据.
请确保当前正在使用的是谷歌 Chrome 浏览器,IE和 firefox 等浏览器暂未测试.
- step 2 : 手动清空
window.name
属性,保证浏览器处于首次打开 12306 网站状态.
因为非首次加载会携带上一次的请求信息,不方便学习验证,经过分析试验发现历史状态还保存在 window 对象的 name 属性,因此仅仅清空缓存还不够,还需要手动清空 name 属性的值.
- step 3 : 强制刷新当前页面并保持记录请求信息,过滤请求类型
js
,找到 /otn/HttpZF/logdevice 请求.
在找到该请求保存查询参数名为 hashCode: owRJc8M4EkFMvcTkzibRFJoDSkUKCx6N9ictZIJLIeY
,方便和之后的计算方式生成的结果做对比.
除了查询请求信息外,更为重要的是查看响应信息,当初次请求 /otn/HttpZF/logdevice 时除了返回过期时间 exp
和 dfp
设备信息之外,还会返回 cookieCode
设备唯一标识.
如果等到过期时间或手动清空站点缓存后,/otn/HttpZF/GetJS 脚本中的相关逻辑会再次发起 /otn/HttpZF/logdevice 请求,那时候的响应内容再也没有 cookieCode
参数了.
让我们再好好看一看初次请求的响应信息吧!
callbackFunction('{"exp":"1581948102442","cookieCode":"FGHcXsVmjf3oV0zm5qTDPFt-VcNhuDA-","dfp":"QNCYH1J5E9M7rl97uo_PUR1OSwRTcCe1xdnbX7h2V6Ewcq6kML0qzXD5y11rLv3FPX1ndOnhL_bjVkwwgtWTsHMFums60_4H9Lr-vJzJGq4tkaUEGfRNXN9IJlvptReSBa5PP7N5gxpSOBo-YlF5Ac98f-YlNlxi"}')
如果将 callbackFunction()
回调函数去掉,不难发现其实返回数据是 json
格式,格式化后发现响应内容如下:
{
"exp": "1581948102442",
"cookieCode": "FGHcXsVmjf3oV0zm5qTDPFt-VcNhuDA-",
"dfp": "QNCYH1J5E9M7rl97uo_PUR1OSwRTcCe1xdnbX7h2V6Ewcq6kML0qzXD5y11rLv3FPX1ndOnhL_bjVkwwgtWTsHMFums60_4H9Lr-vJzJGq4tkaUEGfRNXN9IJlvptReSBa5PP7N5gxpSOBo-YlF5Ac98f-YlNlxi"
}
这里不得不佩服 12306 的设计思路了,故布疑阵,当你误以为自己已经更新了 RAIL_DEVICEID
的值,实际上 cookieCode
的值才是唯一标识而它恰恰没有设置到 cookie 中去,仅仅作为本地缓存保持了,用于再次请求 RAIL_DEVICEID
.
- step 4 : 复制源码实现到控制台,输入
chromeHelper.prototype.encryptedFingerPrintInfo()
获取请求 /otn/HttpZF/logdevice 的查询参数,提取出其中的value
值和真正的请求参数作对比.
假设真正请求参数 hashcode 的值已设置成变量,
chromeHelper.prototype.encryptedFingerPrintInfo().value === hashcode
返回结果true
说明复现算法实现还在正常运行,否则很可能是相关算法又更新了!
直奔重点
如果你正在学习自动抢票或者打算研究如何自动抢票,那么我可以负责任得告诉你,RAIL_DEVICEID
的值绝对是绕不过去的坎,堪称 12306 反爬虫技术的最精华手段!
现在目标已经锁定,赶紧动手和我一起去探究 12306 到底是如何处理 RAIL_DEVICEID
的值吧!
无痕模式下访问网站
众所周知,谷歌 Chrome 浏览器是程序员专属的浏览器,是因为提供了强大的开发调试能力,简单网站请求甚至根本不需要借助第三方专业的抓包工具就能独立完成分析整过程.
如果你还没有听说过 Chrome 浏览器或者正在使用其他浏览器,那么建议你先自行下载最新版 Chrome 浏览器,和文章使用一样的工具有助于顺利复现相关步骤,否则遇到莫名奇怪的问题只能自己研究了.
首先打开 Chrome 浏览器的无痕模式,处于无痕模式最大的特点就是不会保存 cookie,在一定程度上对目标网站而言是新用户(主要指的是新的客户端终端).
输入 12306 官网后,打开开发者控制台(F12或右键检查),选择网络(network)选项卡,确保一直处于监听网咯请求并实时记录状态.
具体而言,最左边的监听状态圆心是红色,保持日志(Preserve log)的复选框已勾选,禁用缓存(Disable cache) 的复选框已勾选,这三者是分析所有网络请求的基础.
准备工作就绪后开始完整走一遍购票流程,即从首页进入登录页,登录并买票等过程,请求步骤越完整可提供分析的资料越多,也就基本上不会遗漏重要步骤,离真相越逼近.
凡是涉及到登录操作,建议先故意输出错误的账号密码等信息,这样有利于登录成功后重定向次数过多而导致无法找到之前的登录请求,如果网络(network)选项卡中的保持日志(Preserve log)的功能没有开启的话,这一现象将会更加严重!
再次输入正确的登录信息成功登录后进行买票行为等操作,但是无需付款,只要正常操作到下单完成即可视为整个购票流程.
下单成功后整个购票流程已经基本完成,接下来开始全局搜索关键字 RAIL_DEVICEID
查看在哪里生成又在何处使用?
全局模糊查找关键字
现在整个购票流程基本上已经完成,接下来开始全局搜索全部请求中是否包含关键字 RAIL_DEVICEID
吧!
首先打开网络(network)选项卡,从左往右数第四个放大镜图标就是搜索功能,输入搜素关键字 RAIL_DEVICEID
会过滤符合条件的网络请求.
不搜不要紧,一搜一大把,只能看出来大部分网络请求都会自动携带该 cookie,反而淹没了到底是哪个网络请求生成的 cookie?
所以必须想办法精确搜索,过滤出生成该 cookie 的网络请求,所以接下来的问题就变成了如果 RAIL_DEVICEID
属于后端直接设置的行为,那么这样的网络请求应该长啥样的?
最好的学习就是模仿,假设并不知道真实的设置过程如何,但是我们可以查看其它 cookie 的设置过程啊!
同样地,在网络(network)选项卡选择第三个过滤器漏斗图标,展开网络请求类型,大致分为 All|XHR|JS|CSS|Img|Media|Font|Doc|WS|Manifest|Other
等类型.
简单说一下网络请求类型的相关含义,整理出表格直观感受一下:
类型 | 名称 | 描述 | 代码 |
---|---|---|---|
XHR | XHR adn Fetch | ajax异步请求 | X-Requested-With: XMLHttpRequest |
JS | Scripts | js脚本 | Sec-Fetch-Dest: script |
CSS | Stylesheets | css样式 | Sec-Fetch-Dest: style |
Img | Images | 图片 | Sec-Fetch-Dest: image |
Media | Media | 音视频媒体 | Sec-Fetch-Dest: audio |
Font | Fonts | 字体 | Sec-Fetch-Dest: font |
Doc | Documents | html文档 | Sec-Fetch-Dest: document |
WS | WebSockets | 长连接通信 | 暂无 |
Manifest | Manifest | 版本文件 | 暂无 |
由于 12306 暂未包括后两种请求类型,所以无法判断该请求有什么特点,除了 ajax 异步请求外,其余类型的网络请求都是通过请求头 Sec-Fetch-Dest
属性标识的,当然设置浏览器的 cookie 也不例外,只不过大多数是通过服务端进行设置的,也就是网络请求的响应头标志了如何设置 cookie 的行为.
在前端 web 开发的过程中并不是一上来就前后端分离的,很长一段时间内前端页面也是由后端人员完成的,因此好多网站至今为止还保留着新旧交替的痕迹.
在上述网络请求类型中,最能体现这种变化特点就是 XHR 和 Doc 请求,XHR的常见封装实现 之一就是风靡全球的 ajax 异步请求,用于实现无刷新局部更新网页内容,而 Doc 是文档类型,无论是直接输出原生 html 还是使用模板技术动态渲染页面,最终输出展现结果一律是 html 文档,这一类的网络请求最容易设置 cookie 之类的请求,体现了上一代技术的一贯风格,恨不得一个人一次性把全部的活都干完!
但是随着技术的发展进步旧技术暴露的问题越来越多,引起了包括开发者在内的业内重视,各大企业已经逐步开始转变,也就是各司其职,物尽其用.
总结来说,XHR 和 Doc 有如下特点:
- XHR 请求的数据绝大多数工作是在前端方面完成的,后端把相关数据返回给前端接口调用者,前端取到数据后进行业务组装展现.
- XHR 请求绝大多数是异步ajax 请求,优点是当前页面不需要刷新就能看到最新内容,缺点是一旦涉及到相互依赖的业务就会出现请求等待噩梦.
- XHR 请求是又前端发起也就是浏览器主动发出给后端,等后端服务器返回数据后继续由前端完成相关业务,这部分数据传输量极少但非常重要.
- Doc 请求大多数是后端在控制,方便设置各种页面元素的表现形式,但是也不排除前端使用相关的模板引擎结合 XHR 数据在控制生成文档.
- Doc 请求设置包括 cookie 在内的一系列网络行为,转发和重定向更是权限控制的常用做法.
下面我们已包括 RAIL_DEVICEID
关键字的网络请求,简单感受一下两者的差异性.
XHR 请求重点在于如何请求数据和接收数据,主要体现在 Request Data 和 Response Data 两方面,至于请求头一般都是默认设置.
Doc 请求的重点就不一样了,绝大多数请求就是输入网址后自动跳转页面,因而关注的重点应该放在请求头和响应头信息上,因为 cookie 的值就是通过请求头进行发送到后端服务器,后端如需新增或修改 cookie 值就是通过响应头进行设置的.
柿子还要先捏软柿子,现在无法判定 RAIL_DEVICEID
到底是服务端直接设置还是客户端自行设置的,而客户端的行为不太直观,所以相对而言还是先捏服务端软柿子吧!
回顾刚才讲解的 Doc 网络请求,不难发现设置 cookie 的行为代码类似如下:
Set-Cookie: JSESSIONID=D4CE095F5A21B38DF3389070F1E01FE6; Path=/otn
现在找到了学习对象,开始模仿查找类似请求的关键字应该是: Set-Cookie: RAIL_DEVICEID=
查无结果!
一般情况下出现无结果很可能是以下原因之一:
- 恭喜您,真的查无结果,可以换条思路继续探索了.
- 很遗憾,当前网络请求数据不足刚好缺失符合条件的请求.
- 日了狗,操作不当误输多余符号或者本是关键字搜索实际上却开启了正则匹配等
所以逐一排查以上原因,首先考虑换一个关键字 Set-Cookie: JSESSIONID
能否查找出相应的结果.
事实胜于雄辩,查询过程并没有任何问题而是查询结果真的不存在,如此一来一次性排查两个原因,那么很有可能生成逻辑在于前端而非后端.
接下来只能在众多请求中碰碰运气寻找前端到底是在何处生成的 RAIL_DEVICEID
,然而请求众多还是要稍微讲究一下策略方向的.
既然是前端在控制 cookie 的生成逻辑,那么很有可能是某个 js 文件在起作用,当然也不排除其他类型的文件有操作浏览器行为的能力.
因此,通过分析进一步缩小范围:在请求类型为 JS
的网络请求中查找包括关键字 RAIL_DEVICEID
的全部请求.
理想很丰满,现实太骨感,本以为选中 js 再搜索关键字能够一起生效,结果并没有,请求类型依旧没有过滤出去,还是一大片的请求!
同时随便选中任意请求可以看到此时网络选项卡搜索匹配大结果其实是请求头这些基本信息,应该并没有包括 js 或者 doc 源码,方向错了,走再多路也是浪费时间.
虽然我不知道你从哪里来,中间经历了什么,但是我知道你的最终归宿一定会落到网络文件系统.
Chrome 浏览器除了可以看出网络请求也能看到最终呈现给用户的文件系统,既然中间过程找不到你,那么我直接到目的地去搜索吧!
打开源码(Source)选项卡,整个面板大致分为三部分,左侧文件数,中间文件区,右侧调试区.
其中左侧的文件结构可以清楚看到当前所处的层级结构,有利于快速掌握项目轮廓.右侧的调试区针对心中有想法但还不确定是否正确提供了非常好的验证工具,调试别人的代码就如本地开发那样边预言边验证.
中间的文件区域面积最大,功能自然也不能太弱,选中左侧具体文件后可以显示源码,方便查看,进而去调试验证心有所想.
所以问题来了,这一切的一切都要源于心有所想才能有行动,那么应该去哪里搜索包括关键字 RAIL_DEVICEID
的文件呢?
一般而言,良好的用户体验是不需要告诉你用户手册的,给你一大堆详尽的说明文档也未必会耐得住去看一篇,先用用再说!
人生苦短,不要浪费太多时间放在无聊的事情上面,简短的三行提醒富含哲理性,第一行告诉你怎么打开文件,第二行告诉你如何运行命令,第三行告诉你如何操作实现什么效果.
如果三行代码还不足以解决你的问题,阅读更多自己慢慢琢磨说明文档吧!
当然,这里和在文件系统中查找包含关键字 RAIL_DEVICEID
的需求最为接近的应该就是第二行,运行命令了,那就试试看吧!
输入搜索 search
后果然弹出了相关搜索命令,于是点击开发工具(DevTools)后出现了搜索框,顺理成章输入关键字 RAIL_DEVICEID
进行搜索了啊!
终于等到你,还好我没放弃,你就是我的唯一,看样子和 RAIL_DEVICEID
有关的处理逻辑全部都在这么一个 js 文件里,看你还往哪里跑!
直捣黄龙还往哪里跑
找到该文件后点击查看,红蓝黑密密麻麻一大片js 代码,绝对不是给人看的而是给机器看的,想要给人阅读还需要美化一下,将源码丑化混淆成难以阅读的代码也是防止他人偷窥复制拷贝自己的劳动成果,同时也能减少文件大小,加速网络传输数据,让你的网站速度更快一些.
点击中间区域的左下角格式化图标进行美化代码,然后在文件中搜素关键字 RAIL_DEVICEID
定位到具体代码.
非常人性化的是,搜索功能是通用的快捷键 Ctrl + F,现在定位到具体代码,截图留念下,接下来才是真正考验技术的时刻!
$a.getJSON("https://kyfw.12306.cn/otn/HttpZF/logdevice" + ("?algID\x3drblubbXDx3\x26hashCode\x3d" + e + a), null, function(a) {
var b = JSON.parse(a);
void 0 != lb && lb.postMessage(a, r.parent);
for (var d in b)
"dfp" == d ? F("RAIL_DEVICEID") != b[d] && (W("RAIL_DEVICEID", b[d], 1E3),
c.deviceEc.set("RAIL_DEVICEID", b[d])) : "exp" == d ? W("RAIL_EXPIRATION", b[d], 1E3) : "cookieCode" == d && (c.ec.set("RAIL_OkLJUJ", b[d]),
W("RAIL_OkLJUJ", "", 0))
})
本地备份js方便复现
既然已经找到关键文件,自然需要留存快照进行存档操作,否则哪一天文件更新了都不知道哪里发生变化了,难不成还要从头再分析一遍,我选择差量更新而不是全量覆盖!
选中源文件右键弹出菜单,选择任意一款喜欢的方式复制源文件到本地留作学习备份,准备工作就绪后准备大干一场.
未完待续
由于篇幅有限,一篇博文不得不分成三部分,对此造成的不良体验,还请见谅,如需阅读剩余部分,关注雪之梦技术驿站不迷路.
如果你觉得本文对你有所帮助,请随手点个赞再走呗或者关注下公众号「雪之梦技术驿站」定期更新优质文章哟!
来源:CSDN
作者:雪之梦技术驿站
链接:https://blog.csdn.net/weixin_38171180/article/details/104342405