第一题 F12 即可看到,或者抓包
第二题页面指示 bilibili Security Browser
浏览器,改 User-Agent 为该值请求,拿到 flag
弱密码,虽然其实解出来比较无语
甚至差点把 <meta>
标签当 flag 了,结果发现每个页面都有
首次进入 flag 为空,抓包发现塞了个 cookie role
从 cookie 下手,搜索这个哈希值,得到是 md5 计算后的 user
页面指示需要超级管理员权限展示,将对应的文本 md5 塞入 cookie role
即可,结果试了 root
admin
superadmin
等关键字都不对后怀疑人生,Google「超级管理员」后才想起来 Windows 这回事,这也太无语了,还区分大小写
F12 给了个 uid 100336889
,因为提示「这里没有你想要的答案~」所以在 B 站上找该用户,发现粉丝里有几个是之前上榜的,怀疑是关注后自动回复,然而关注和发消息并没有任何信息
试了下其他的几个可能的 uid,比如自己的 uid,bishi 的 uid,徐逸的 uid,陈睿的 uid,0,1001,这人关注的几个人的 uid 无果,暴力破解感觉不合理,已到深夜五点,放弃
睡醒后尝试找灵感,发现活动帐号有人提到真是暴力破解,草
于是写了个脚本从 0 开始跑,跑了一会觉得太慢就再起一个从 100000000 开始跑,结果没一会就跳出了,就在给的 id 后一点,草
给了个博客页面,id=1,别又是暴力破解吧,先从 1 开始暴力扫
评论没什么实际价值,可以忽略
搜了下页面的关键字,搜到了这个博客的源码 https://github.com/sengeng/blog,不过估计就是只是用了样式而已,毕竟这个是 python 写的,而题目是 php
试了下些常用路径,比如 login.php, admin.php, /admin, /phpinfo.php, /info.php, /phpmyadmin 无果
顺带跑了个 sqlmap 也没结果
难道不是 web 题?服务器扫端口,扫出来个 6379 端口,尝试 telnet 发现会自动回文输入文本的第一个字符之后的内容,怀疑是不是关键词匹配,摸索了会发现开头输入 * 会强制 tcp reset 并断连,之后一段时间都无法连接。尝试以此突破,然而摸索了会还是无果,倒是不知道输入了什么服务器返回了 OK,然而没抓包,无法复现。
扫了会放弃了,晚上准备点外卖时大佬提到后面有关于 redis 之类的题,试了下搜索 6379,正是 redis 端口
用 redis-cli 连接,列出缓存 key,发现 flag1~flag10,逐一列出尝试,拿到 flag 8
回到第 6 题,后续搜了下社交媒体,终于从微博上找到了别人留下的 hint
打开 test.php,一堆 jsfuck,控制台执行,指示 GitHub 搜索关键词
得到 end.php 以及实现思路,~~以及大佬的解答~~
阅读源码构造参数测试,测试路径
得到图片一张,保存下来
vim /flag
找到 flag 10
另外文件名看着像 flag 但是试了下好像并没有命中
顺带这题看 https://blog.skk.moe/post/2020-bilibili-1024-ctf-write-up/#%E7%AC%AC%E5%8D%81%E9%A2%98 的解析才想起来源码里有个 file_get_contents()
,如果是真的那就好玩了,想起来这个站点里也有个 php 是为了方便代理下载文件就偷懒写 PHP 的 curl 直接用了万能的 file_get_contents()
,后来才发现 url
传个路径就直接读本地文件了,重大漏洞
试了下居然是真的,但是看代码不是很懂真的放个 file_get_contents()
在这是为什么
尝试从源码构造请求无果
微博有人说扫到了 21 和 1433,然而连不上
用 nmap 扫出如下端口,然而除了 6379 连不上
PS D:\Reserved\Users\ccloli\Downloads\nmap-7.91-win32\nmap-7.91> .\nmap -p- 120.92.151.189 Starting Nmap 7.91 ( https://nmap.org ) at 2020-10-24 19:44 ?D1ú±ê×?ê±?? Nmap scan report for 120.92.151.189 Host is up (0.090s latency). Not shown: 65530 filtered ports PORT STATE SERVICE 443/tcp closed https 1194/tcp closed openvpn 6379/tcp open redis 8069/tcp closed unknown 8091/tcp closed jamlink Nmap done: 1 IP address (1 host up) scanned in 648.57 seconds
连不上估计是因为大家已经把前 5 题都解出来了,估计在暴力破第 6 题(毕竟前五题的那个服务器已经换了三次 IP 了,后五题的服务器也在 10.24 晚从上海估计是幻电的服务器迁到了那个所谓的高防服务器上)
除了 8 10 以外,其他的 6 7 9 感觉和自己没什么关系了,就先到这吧,有这闲心不如肝橙汁
既然基本上大家都卡在 6 7 9 就把文章放出来好了,毕竟题解已经满天飞了,不如来捞点流量
又睡了一觉起来发现大家都卡在 90,迷惑,结果发现服务器又爆破炸了
更新 2020/10/26:
第 6 题解法和之前博客里提到的一样,Referer 存在注入,虽然对于自己来说即便知道有问题也不知道怎么用,过滤掉 select
之类的关键字能看明白,剩下的语句就完全看不懂了,果然还是菜
https://github.com/interesting-1024/end/issues/283
第 9 题正由 rr 主导破解中
https://github.com/interesting-1024/end/issues/281
做题累了就来看看色图吧
https://github.com/interesting-1024/end/issues/228
flag7 出来了,草,这 API 看着怪怪的一开始就注意到了,谁知道层级居然这么多
https://github.com/interesting-1024/end/issues/293
之前试了会碰撞失败就放弃了,谁想到居然有三个 ../
啊 kora
flag9 正在等待破解?欢声笑语中打出 GG
https://github.com/interesting-1024/end/issues/291
{"code":200,"data":"SkRGWDZRZnJxelJQU21YME42MU04OWlwV1l0SlYvcEJRVEJPWWFYUXVHOGZBcnJ1bjNXS3hXRlpHd05uMjFjRw==","msg":""}
{flag9:11451419-19810cao-hengheng-aaaaaaaa}
好吧,可能是这个
https://github.com/interesting-1024/end/issues/301
更新 2020/10/27:
yh 大佬写的全篇 write up,别说了,膜就是了
https://github.com/interesting-1024/end/issues/311
flag9 的实际解法,啊这,AES 能勉强猜到,但是居然还存在一个差异的图片文件,根据密文的 md5 得到图片的文件名,这也太脑洞了
https://github.com/interesting-1024/end/issues/315
又到了惯例的「ロリの日」,虽然只剩最后一个小时了,而且这两天也并没有看点兔三期,不过还是来扯些和ロリ相关或者不相关的东西吧。
去年提到了购入了个 lolicon.icu 的米,毕竟当时注册价格才不到两刀,而且 icu 也算好记,既有萝莉控 ICU 病房的意思,又有 lolicon I see you 的意思,所以还算有点意思,也因此 loli.icu 直接被人抢注了 10 年。然而今年年初续费前收到了 Namesilo 即将自动续费域名的邮件,顺带登帐号上去看了下,发现续费价格居然涨了,不再是 2 刀了,而是 6 刀了。虽然其实还好,但是当时脑抽感觉被背刺了,于是就把自动续费的开关关了,想着反正之后域名放出再注册价格还是两刀。于是……如你所见,这个域名当然已经不是我的了,现在想想好像有点小亏,而且这域名比那个完全摸不着头脑的 uno 好记多了……不说了,血亏。
同样血亏的还有上个月忘了续费 loli.io 的 某人,等他发现的时候域名已经被挂在 park 页面上竞价了。顺带去围观了下,域名起价 99 刀。竞价嘛,肯定要高出域名价格的,不过考虑到 .io 域名本身就三十多刀,所以其实这个起价应该还好。park.io 的竞价周期是 10 天,而竞价嘛,肯定前几天没什么人起价,所以前几天域名基本就是在 100 刀这样。直到最后一天,价格竞价到了 1000 刀……
总之 loli.io 大概就这么没了,虽然不知道为什么 NAS 上的 CrystalDiskInfo 还是能用这个域名的邮箱发警告邮件,难道网易企业邮箱不会重复校验 MX 合法性?
查了下 MX 记录,也没数据,不过看这 DNS 信息,看来是被 Snowfom 拐走了(
C:\Users\ccloli>nslookup -type=MX loli.io a.dns.sb 服务器: a.dns.sb Address: 188.244.98.132 loli.io primary name server = a.dns.sb responsible mail addr = sb.sb.sb serial = 2020092417 refresh = 3600 (1 hour) retry = 600 (10 mins) expire = 604800 (7 days) default TTL = 180 (3 mins)
不过现在想想,1000 刀是不是其实也还好,比如 Namesilo 里挂着的另一个 loli 域名竞价是这样的:
这么一想倒是有点担心之前瞎注册的 loli.uno 了,于是趁着近期域名到期续费时提前把 loli.uno 也续费了。顺带看了下近期 Namesilo 的域名价格,发现 .uno 现在注册费居然也只要 2 刀,血亏……虽然续费价格还是 18 刀。然而在订单页面结算时,发现这个域名居然被标记为了 Premium Domain,而且一次只能续费 1 年。啊这,NIC 这算趁火打劫吗???不过虽然是 Premium Domain 但是为什么价格居然还比标称价格便宜了,迷惑。
总之大概就先这样吧,这又是没啥萝莉属性的存活报告,不如再塞张ロリ?
不知不觉又一年过去了呢,嗯,暂时没什么想说的,毕竟只是日年常报平安罢了。
和过去差不多,浑浑噩噩又过去了一年,估计也早就离所谓「萌」的道路越来越远了吧。
先这样流水账吧,今晚来集点兔打个鸡血吧,明天再说些和「萝莉」相关的事好了(?
]]>
canary
通道,某些精神洁癖的同学还是会不太乐意。
那么我们可以换种思路,为何我们不在本地自建一个 npm registry?这样可以随意发布测试,不会踩到本地安装 npm 的坑,还不会影响到线上包。Verdaccio 就是一个随开随用甚至无需配置的 npm 仓库,用户不需要自己搭建数据库,只需要一行指令即可在本地启动一个 npm 仓库。
太长不看版
npm i -g verdaccio verdaccio npm adduser --registry http://127.0.0.1:4873 npm publish --registry http://127.0.0.1:4873
或许你会好奇,npm 是支持本地安装包的,原理就是在 node_modules
下添加一个 symlink,指向对应的目录:
npm i ../path/to/my/package
同时刷新 package.json
的依赖:
{ "dependencies": { "my-package": "file:../path/to/my/package" } }
这样安装在一般情况下是没问题的,node 在运行时能找到对应的包。问题在于这样的操作只是添加一个 symlink 而已,而这个包里的依赖不一定会同步复制到当前的 node_modules
下。如果这个包里的依赖只是在这个包用上的话,一般不需要考虑这个问题。但是如果这个包下的依赖同时是项目下的其他依赖需要的依赖,那么就会遇到找不到依赖的问题。
具体到实际的例子,我们正在维护一份包含 ESLint 等 lint 规则的包,这个包会包含 lint 规则所需的所有依赖(如 eslint-plugin-react
、eslint-plugin-import
等),这样用户不需要手动安装这些依赖。然而由于本地安装不会处理本地包所使用的依赖,也就是不会复制到 node_modules
目录下,而这些依赖是被项目的 ESLint 等依赖使用的,最终会导致 ESLint 等会找不到对应的插件。
这种情况的解决方法只能是手动将对应的依赖复制到 node_modules
下,但是一个个手动复制未免也过于繁琐了。所幸已经有人开发了 npm-install-offline
这样的包来解决这种问题:
npx npm-install-offline my-package --repo ../path/to/my/package --production
它的原理就是将项目所需要的上游依赖包复制到当前项目的 node_modules
下,这样项目在运行时就能找到这些依赖了。但是这个包在应对 lerna 这类 monorepo 时会有意想不到的 bug,lerna 在 bootstrap 后会将关联的包在 node_modules
下用 symlink 连接起来,而这个包无法处理 symlink,导致复制对应的依赖时会卡住。虽然这种情况下 npm-install-offline
还可以指定 --symlink
参数也使用 symlink 连接:
npx npm-install-offline my-package --repo ../path/to/my/package --production --symlink
这种情况下依赖就能顺利链接到 node_modules
下了,一般也能解决不少问题。然而对于之前提到的实际中的例子,包里的依赖其实不仅仅是被项目依赖如 ESLint 调用的,同时自身也需要调用项目中的依赖,例如 eslint-plugin-import
就需要 require('eslint')
。然而 symlink 的依赖在寻找上游依赖时可能就犯了难,会抛出 Cannot find module 'eslint'
的错误,盲猜是依赖执行时并不是 symlink 后的项目所在的 node_modules
下寻找,而是在原来的目录下寻找,最终导致找不到依赖。
总之 npm 处理本地包的依赖大概就是一坨,如果你的包也是这样复杂,建议尽早放弃本地安装的思路,否则可能还会有更多的坑。
既然本地安装目录不靠谱,那么 npm pack
呢?不好意思,npm pack
并不会打包 node_modules
,安装后依赖还是从远程仓库上拉的,对于 monorepo 并不友好。所以此时更好的方法应该是直接发包并安装测试,可是这样有可能会影响已经发布的包版本。那么我们何不自己搭建一个 npm 仓库?
自建 npm 仓库是可行的,最常见的方案就是 cnpm,而自己搭建 cnpm 则还需要准备数据库等环境依赖。那么有更快更简单的方法吗?当然有,比如这次要用上的 Verdaccio,它是一个开源轻量免配置的 npm 私服,用户不需要准备本地的使用环境,只需要安装即可使用:
npm i -g verdaccio verdaccio # 或者也可以使用 docker docker pull verdaccio/verdaccio docker run -it --rm --name verdaccio -p 4873:4873 verdaccio/verdaccio
启动后 Verdaccio 会默认运行在 127.0.0.1:4873
上,你可以打开浏览器确认:
此时我们只需要将 npm 的 registry 指向本地,就可以开始操作了。
# 设置全局 registry npm set registry http://localhost:4873/ # 或者设置环境变量 NPM_CONFIG_REGISTRY=http://localhost:4873 # 或者在测试的项目下设置 .npmrc 文件并填写 registry registry=http://localhost:4873 # 或者在每次执行 npm 时指定 registry 参数 npm --registry http://localhost:4873
首先我们需要登录本地仓库的 registry,用户名密码其实可以随意:
npm adduser --registry http://localhost:4873
然后我们就可以将包发布到本地的仓库下了:
npm publish --registry http://localhost:4873 # 如果这是一个 lerna monorepo npx lerna publish from-package --registry http://localhost:4873
如果不出意外,你可以在刚才的 web 页面上看到刚刚发布的包了。
在其他项目下测试时,只需要在安装时指定 registry 即可:
npm i my-package --registry http://localhost:4873
这样你就在本地搭建了一个运行在本地的 npm 仓库,现在你可以随意折腾你的包并随意发布了,本地发布到 Verdaccio 的包是不会发布到上游的。
Verdaccio 的配置文件默认放在用户目录下,一般是取 $XDG_DATA_HOME
环境变量,Windows 是取 %APPDATA%
环境变量。此外在启动 Verdaccio 时也会输出配置文件的位置,个人实践的配置文件位置如下:
# Ubuntu 18 ~/.config/verdaccio/config.yaml # Windows %APPDATA%\verdaccio\config.yaml
Verdaccio 默认是绑定在 127.0.0.1:4873
下的,如果需要绑定到公网 IP 可以改为监听 0.0.0.0
,此外也可以修改端口。
listen: - localhost:4873 # 默认配置 - http://localhost:4873 # 同上 - 0.0.0.0:4873 # 监听所有 ipv4 地址 - https://example.org:4873 # 如果需要使用 https - "[::1]:4873" # ipv6 - unix:/tmp/verdaccio.sock # unix socket
如果你是采用全局配置 npm registry 的话,如果使用了公司内部的包,那么本地安装上游的包时可能会遇到找不到依赖的问题,因为 Verdaccio 虽然默认会代理上游 npmjs 的包,但是私有仓库是不支持的。此时你需要在安装原始项目依赖时用默认的 registry,然后安装测试的依赖时指定 registry。其实我们可以通过配置上行链路让 Verdaccio 自动查找上游的包,这样就只需要将 registry 指向本地即可。
Verdaccio 的默认上行链路配置如下:
uplinks: npmjs: url: https://registry.npmjs.org/ packages: '@*/*': # scoped packages access: $all publish: $authenticated unpublish: $authenticated proxy: npmjs '**': # allow all users (including non-authenticated users) to read and # publish all packages # # you can specify usernames/groupnames (depending on your auth plugin) # and three keywords: "$all", "$anonymous", "$authenticated" access: $all # allow all known users to publish/publish packages # (anyone can register by default, remember?) publish: $authenticated unpublish: $authenticated # if package is not available locally, proxy requests to 'npmjs' registry proxy: npmjs
我们可以在 uplinks
里指定上行链路,然后在 packages
里指定对应的 scope 走对应的链路。例如我们可以将公司的私有 scope @foo
指向公司的 cnpm,然后默认的依赖走淘宝的中转,配置可以写成这样:
uplinks: npmjs: url: https://registry.npm.taobao.org/ company: url: https://registry.my.company.com/ packages: '@foo/*': access: $all publish: $authenticated unpublish: $authenticated proxy: company '@*/*': access: $all publish: $authenticated unpublish: $authenticated proxy: npmjs '**': access: $all publish: $authenticated unpublish: $authenticated proxy: npmjs
然后我们可以在需要测试的项目下删除原有的 .npmrc
,然后安装依赖时直接使用本地的 Verdaccio,或者直接将 .npmrc
指向本地的 Verdaccio,它会帮我们代理好上游的包,不再需要来回切换 registry 了:
npm i --registry http://localhost:4873]]>
TL;DR. 本地搭建一个转发 http 与 https 请求到 https 的代理服务器,并签发一个 *.qq.com
与 *.vip.qq.com
的自签证书并信任,然后本地修改 hosts 将 proxy.vip.qq.com
与 verify.qq.com
指向代理服务器即可
本着「又不是不能用」的原则,本地的软件基本不保持更新,所以本地 QQ 也保持在了 8.9.3 版本。
结果某一天作死开了个 QQ SVIP,再次登录时 QQ 提示需要输入独立密码同步信息,结果输入密码后点击确认,没有任何反应。
于是接下来就是喜闻乐见的作死环节了,为了尝试恢复同步消息,在 QQ 的设置里将「同步最近消息到本地」的勾选框取消了。结果重新勾选的时候,弹出了需要输入独立密码,同样无法输入,甚至还无法改变聊天记录同步时间,提示服务器超时。
估计是 API 服务器不兼容旧版 QQ 了,于是在虚拟机上装了个最新的 QQ,结果大片的空白和间距感觉要吐了,老年人审美决定还是不升级了,于是问题回到了怎么折腾 8.x 上的独立密码的问题,唯一的收获是在新版 QQ 上是没问题的。顺带查了下版本号才发现原来 8.9.6 就是 8 的最后一个版本。
首先先启动 Wireshark 抓包,筛选条件过滤 http,点击 QQ 消息的设置按钮,结果还真发现了一个域名为 proxy.vip.qq.com
的 http 请求。追踪 tcp 流,发现这个请求最终 301 到了 https 的链接上。
然而以目标 IP 为搜索过滤条件时,却并没有找到对应的 TLS 连接,怀疑是不是浏览器的版本过老,没有自动进行重定向,导致获取数据失败。
仔细观察了下 HTTP 报文,发现 Chrome/29.0.1547.59
这个 User-Agent 有点诡异,这也太老了吧。既然是 Chrome 的 UA,那估计是用了 CEF,结果果然在 Tencent\QQ\Bin
目录下发现了一个 libcef3.dll
的文件……饿,虽然印证了猜想,但是为什么要数 3 呢……
虽然考虑到 libcef 有可能是自己修改了源码并编译的,不过死马当活马医,从 Spotify 上下载了 预编译的 libcef,然后解压文件,将 libcef.dll
重命名为了 libcef3.dll
,重启 QQ,结果果不其然,QQ 崩溃了(顺带还发现原来聊天记录的展示页也是通过 CEF 展示的……万物皆可 webapp)
既然开源的构建版本不能用,那就从新版 QQ 里找一个覆盖一下,估计同一套接口兼容性会好很多。结果在最新的 QQ 里,libcef 没了,取而代之的是一个叫 qbcore.dll
的玩意……好嘛,看到文件名相似的 .pak
文件,其实还是 CEF 魔改的。于是尝试把相关文件直接覆盖 libcef3.dll
,结果果不其然还是崩溃了。既然新版本不行,那就尽可能找个最接近的版本号呗,结果 8.9.6 也是 qbcore.dll
……感情没赶上重构的末班车
既然魔改内核失败了,那就只能想想其他法子了。既然 QQ 不会自动跳转,那思路就变成了搭一个 MITM 服务器,接收 http 请求,然后向上游服务器直接发起 https 请求,再把数据吐给之前的 http 请求,问题不就解决了?于是接下来照着这个思路在树莓派上写了一份 nginx 配置。饿,至于为什么不在本地写?因为本地 80 端口被本地的 Apache 占用了,所以直接在树莓派现成的 nginx 上改了。
server { listen 80; server_name proxy.vip.qq.com; location / { proxy_pass https://220.249.244.31; proxy_set_header Host proxy.vip.qq.com; } }
接下来修改 hosts 将 proxy.vip.qq.com
指向树莓派的 IP,重启 QQ 以清空 DNS 缓存,打开消息记录里的「设置」,成功加载出了消息设置。
但是……输入独立密码,还是无法点击确定。
期间折腾了半天,找不到原因,甚至还试了开启设备锁,结果设备锁没用,还导致手机 QQ 不能再使用独立密码验证了,只能通过设备锁验证,吐了。
最后再仔细观察了下请求的报文,结果发现「管理独立密码」这个弹窗里同时还有一个 verify.qq.com
的请求……再一看一开始的报文,发现当时的请求里就在 Origin
这个 header 里指明了 verify.qq.com
这个域名,表明这个请求是从这个域名的页面上请求的。结果反而折腾了这么久,原来一开始就留下线索了……
于是将 verify.qq.com
写入 nginx 配置并修改 hosts,重启 QQ 重试。然而……事与愿违,点击确认按钮还是不通过。再根据 IP 查请求上下文,发现这个域名其实可能是有 https 连接的,但是代理服务器只支持 http 连接,导致验证不通过。这……一会 http 一会 https 的,为啥不一开始就统一啊 kora
最后签了一个 *.qq.com
与 *.vip.qq.com
的自签证书,然后将证书放在受信任的 CA 证书存储下,再在 nginx 上调整配置如下以接受 https 链接,并自适应转发到对应的 upstream。
server { listen 80 default_server; listen 443 ssl default_server; server_name _; ssl_certificate /usr/share/nginx/ssl/qq.crt; ssl_certificate_key /usr/share/nginx/ssl/qq.key; ssl_protocols TLSv1 TLSv1.1 TLSv1.2; ssl_ciphers 'ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA:ECDHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-RSA-AES256-SHA256:DHE-RSA-AES256-SHA:ECDHE-ECDSA-DES-CBC3-SHA:ECDHE-RSA-DES-CBC3-SHA:EDH-RSA-DES-CBC3-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:DES-CBC3-SHA:!DSS'; ssl_prefer_server_ciphers on; resolver 119.29.29.29; set $backend https://$host; location / { proxy_pass $backend; proxy_set_header Host $host; } }
重启 QQ,问题成功解决,设置里的同步聊天记录的复选框也可以勾选了,输入独立密码点击确认,消息成功同步到本地。
垃圾 QQ 净折腾人,开了会员还不省心,下个月不开了
]]>云音乐 base 杭州为主,若有需要请参考如下步骤:
标题:【云音乐内推】[姓名]-[职位] 内容: 姓名: 手机: 邮箱: 职位: 职位页 URL: 说明:
注意事项:
hi,好久不见,不知还有多少人记得这里。其实说是年度总结,倒不如说是年末总结,毕竟前几个月已经基本忘得一干二净了,那么就权当是季度总结了。
10 月末的时候入手了个显示器作为外接屏幕(虽然其实可能并不用得上),主要看的是几个廉价 4K 显示器,虽然 Windows 的高分辨率即便在 Windows 10 下还是比较屎。最终确定了两个型号,一个是三星的 U32J590UQC,一个是优派的 VX2780-4K-3。二手东上在最优惠时的价格差不多,而三星的价格一般会更高一些,再加上也没体验过 VA 的效果,所以决定先试试看三星。
三星的这个显示器是 32 寸的 VA 面板,上市时间是 2017 年,据说当年在某外媒被列入了最佳 4K 游戏显示器之一,因为相比较之下是最便宜的 4K 显示器还有完整的 FreeSync 支持。从 displayspecifications 上看,使用的是 群创的 VA 面板,最大 60Hz 带完整 FreeSync,亮度标称大约有 270 nit,8 bit FRC 抖 10 bit。由于是 VA 所以拥有较高的 3000:1 静态对比度,虽然亮度比较低,但是由于静态对比度比较高,所以在低亮度场景下可以看到更深的层次(此处存疑,也有说这与对比度无关,而与面板本身的色深有关)以及更艳丽的场景(此处同样存疑,这有可能是显示器出场校准偏色,至少从 RTINGS 的评测来看色准不行,实际也是如此,画面整体偏红)。而且对于 4K 来说,32 寸刚好是个比较良好的尺寸,像素密度大概和 1080P 的 15.6 寸笔记本屏幕差不多,所以其实是个挺不错的显示器。
实际到手使用的时候,发现色彩确实非常艳丽,观感效果也非常棒,检查了一下也没什么问题。但是问题也就出现在艳丽上,整个画面明显偏红,而且有些红的不自然。而且由于屏幕比较大,在一下子看不了整个屏幕的情况下是有 VA 自带的泛白偏色的。虽然比垃圾 TN 的上下反色好很多,可视角度也好不少,不过如果注意到的话还是会有点奇怪,大概就像下面这样。
不过偏色和泛白其实还好,熟悉两三天后就习惯了,所以其实不算什么大问题,毕竟人眼更喜欢艳丽的色彩,哪怕不真实(校色?不存在的),而且泛白至少比 TN 的偏色好。低亮度下(< 30%)是 PWM 调光,所以在高帧率拍摄下是能看到摩尔纹的(顺带一提居然还是红色的摩尔纹),不过其实一般不会调低亮度(况且亮度本身就比较低),所以也可以忽略不计,毕竟大学 4 年用的 Acer E5-572G 就是垃圾 TN 屏 + PWM 调光。 但是这回就到这个显示器最大的问题了,亮度不够。虽然 VA 在全黑或者暗画面下效果非常出色,但是在屏幕全白时亮度明显比不过 G3 笔记本自带的 300nit 屏幕。而且在之前提到的评测网站 RTINGS 上,测试人员测试出来的屏幕亮度居然其实只有 200nit 左右……未免也太寒酸了。在尝试游玩 ~~盗版的~~ 莱莎与炼金工房时,使用 OBS 复制画面到笔记本屏幕,虽然感觉显示器的色彩非常饱满,但是也明显感觉在较高的亮度下笔记本的视觉效果反而更好,谁让 HDR 是趋势呢。
最后折腾了三天,还是再在二手东上购入了优派的 VX2780-4K-3 做对比,~~毕竟京东 7 天无理由退货呢~~。
显示器到手明显感觉 27 寸比 32 寸小了一圈,像素密度比 14 寸的 1080P 屏幕还要高。而且虽然这款显示器之前爆出使用的是 LG 的面板,而且在京东商品页和官网的参数是 IPS 的 1300:1 对比度,在屏库网上这个对比度也只对应上了 LG 的面板。但是其实从京东评论区的反馈来看,这款 LG 面板后来爆出了四角泛红的雷,所以这个系列的新产品,包括 LG 自己的低端 4K 显示器都换成了京东方的面板(京东评论里还有人晒出了工厂模式的图),自然对比度也变成了垃圾 IPS 的 1000:1,~~不知道是不是能告欺诈消费者~~。最后到手进入工厂模式,果然是京东方的面板,不知没有泛红但是对比度不高算是好还是坏。不过除此之外其他的参数没什么问题,典型亮度标称 350nit,比三星的那个 VA 不知高到了哪里去。其他的参数倒是有细微差别,虽然是 8bit 抖 10bit,但是是全程 DC 调光,最大 60Hz 但没有 FreeSync(不过 N 卡也不支持在 HDMI 2.0 下运行 G-Sync 兼容),而且 IPS 也没有 VA 的泛白问题,还附带了一个比较鸡肋的 HDR10……额,350nit 的 HDR 能好到哪去,HDR400 都达不到,而且这个菜单未免也太简陋了……
实际上机测试,会发现高亮度下带来的视觉效果真的好太多了,如果莱莎里正午的光照在优派的显示器上作为参照的话,三星的显示效果就像午后太阳开始偏西一样。但是在较暗的图片下(比如使用了 Bing 首页的各类背景图做对比),会发现 VA 的暗部细节比 IPS 好不少,毕竟 IPS 漏光比较可怕,画面全黑的时候屏幕还是比较亮的,这也算是 IPS 通病了,如果在暗室使用对亮度也没要求,VA 无疑是更好的选择。
当然 VA 最大的问题就是黑白电平转换过慢,在 testufo 的文字滚动测试下,VA 有更明显的视觉残留,而提升响应时间而引入的 overdrive 又让画面出现了过载虚影,显示的效果反而更糟糕了。相比较之下 IPS 只需要较低的 overdrive 就能达到不错的显示效果。
不过最后就是真正纠结的地方了,为了测试游戏的效果选择了使用巫师 3 来测试游戏画面,结果发现巫师 3 其实非常适合用来测试屏幕素质。巫师 3 的里可以方便的控制日夜转换,因此可以测试显示器在高亮度画面和低亮度画面下的显示效果,而且血与酒 DLC 里的新地图是一个非常好的测试环境,因为游戏画面里的景色拥有非常高的对比度,可以直观的看到显示器本身的色彩素质。结果也正如之前说的那样,三星的 VA 面板可以看得到非常艳丽的色彩,再加上 32 寸的屏幕,色彩效果很有冲击感。但是问题还是出在低亮度上,和优派的 IPS 对比,画面就像有一层遮罩,尤其是在白天使用猫头鹰魔药时,优派的 IPS 屏幕相比较之下更白更刺眼(真实闪瞎),而三星的 VA 面板看起来明显不够亮,白光的区域反而显得看着像灰色。但是 VA 在暗景下还是有优势的,暗景下屏幕本身就比较黑效果自然也更好,而且在游戏黑夜里骑马奔跑时 VA 的高对比度(也有可能是响应时间过慢)使得屏幕能更清晰地展示远景的轮廓。
总之……两者还是难分伯仲,两者都有各自的优点,VA 相比之下优点更多缺点也更多,但是抛开这些不谈,两者的屏幕素质都非常不错……最后考虑了很久,还是选择了优派的 IPS 屏幕……啊,要是有同时结合两者的优势的屏幕(包括便宜)该多好……
目前在用的笔记本是一个二手的 Dell G3 笔记本,其实……笔记本本来是没什么问题的,可以正常使用,就是有时用着用着 PCH 温度过高会直接关机,而且还挺频繁的。游戏开始后不到半个小时,耳机里的声音就开始出问题了,这就意味着南桥已经过热到 110 度了,到了 120 度左右就会强制断电。虽然其实笔记本本身的 CPU 和 GPU 温度就偏高,比如在游戏时降到 1.5GHz 但至少游戏还是能玩的(感谢一些游戏的多核优化)。
既然温度偏高,那就换个更强劲的硅脂呗。其实……已经没有更强劲的硅脂了,因为这台机器在上一任主人手里换成了秒杀所有硅脂甚至焊钎的液态金属(对,暴力熊那个),这玩意可是导电的。当时机器到手时笔记本外壳还因为快递运输漏出来了点液金,当时还吓得不轻。但是现在温度比较高也有可能是液金漏到其他地方导致无法散热了,再考虑到可能存在的搬运需求,还是硬着头皮上好了。
结果,一切顺利,拆开 D 面然后顺利把散热片拆下,啥事没有,用棉签清理掉就完事了——啊逗你的,不如说遇上了大麻烦。GPU 的部分没什么问题,液金基本上都待在晶片和周围涂上硅橡胶的 PCB 上(虽然一般是用三防漆,不过橡胶不导电……也还行,就是有点丑)。就是 CPU 比较惨,CPU 的液金在散热器抬起来的时候顺着绝缘胶带顺着流到主板上了!
太惨了,担心的事情还是发生了,只能强行清理了。首先先清理掉散热器上的液金,结果发现散热器表面已经开始腐蚀了,表面镀的铜已经消失了,露出了银白色的粗糙底面…… ~~请写出对应的化学方程式~~
然后是清理 CPU 和 GPU 表面的液金,用棉签小心擦拭就能勉强带走一些大的颗粒,但是液金不像水,是无法直接吸附的,所以分了好几次才清理干净(啊,如果之前酒精没用完就好了)。最后清理主板表面的液金,比较尴尬,流出来的时候就已经沾到了电阻上了。然后这里就犯了第一个错误,因为没有酒精棉签没有很强的吸附能力,结果用棉签清理时反而让液金变成了更小的颗粒,最后 CPU 那一片已经变成了银白色……太惨了……最后折腾了老半天,棉签纸巾齐上阵(啊,当时已经太晚了买不到酒精了),终于把表面的液金清理的差不多了……
然后犯了第二个错误,在某一次擦拭的过程中,把 CPU 旁的一颗电阻擦掉了……
………………
………………
………………
运气不错,擦掉的电阻没跑走,至少还有希望。在将主板的液金基本清理掉之后,开始犯下第三个错误——找出焊笔尝试把电阻焊上去。大概看了下视频,就是把焊锡往焊点上一抹然后迅速把电阻贴上去嘛,没问题的!诶,怎么这焊锡挂不上,诶,电阻你别跑啊,诶,要不把焊锡垫高点然后从旁边点上去,诶,要不直接用焊笔加热电阻然后旁边点焊锡吧……
最后,终于强行把电阻挂上去了,好,看起来差不多了,死马当活马医了,开机作死试试。好,结果果然不出所料,开机黑屏,甚至连 POST 画面都没有,GPU 发热,CPU 不发热,看来还是那个电阻的过。诶,难道是虚焊?要不再重新试试看。折腾到凌晨后,诶,电阻都已经从黑色变成灰色了,怎么死活挂不上了……诶,怎么 CPU 边缘还有点液金,不会给渗到 CPU 针脚下了吧……最后发现高温的罪魁祸首,原来是风扇被灰尘堵住了,其实和液金没什么关系……然而这并不意味着一开始只拆风扇就行了,因为这傻吊散热器的风扇是和热管焊在一起的,如果要拆风扇,首先需要拆掉整个散热器,然后再翻过来拆风扇的螺丝……
折腾一夜无果后,只好作罢。第二天重新用酒精清理了下,然后送到小区隔壁的电脑店,简单说明情况,【哎呀你这太牛逼了,电阻都能给你整掉下来】。然后仔细看了下机器,再看了下电阻,【你这用的是高温电烙铁吧?这电阻焊点都没了。你这不好搞啊,这种小电阻都没有阻值的,给你换一个也不知道换啥,只能用你的这个了。你这情况太特殊了,要焊的话 50,而且我只管焊上去,不管能不能用】。行了,都这样了,死马当活马医了,先试试吧。五分钟后,【你这不行了,焊点都没了,我往上挂焊锡根本挂不上。你这如果要修,得要找图纸,找出对应的电阻是啥阻值,如果要这么做的话焊上要 X00(具体多少忘了,300?500?800?),如果没有图纸的话那就没救了】。
行,那就凉了呗。结果那天晚上用着之前的 Acer E5-572G 查维修的时候,发现还有主板交换这一套。简单来说就是把主板寄给淘宝卖家,卖家拿主板给工厂然后得到新主板。看淘宝有 Dell G3 的主板交换,但是价格比较悬,店家列的都是最低配的 i3 无显卡主板,这都要一千多了,那手上这个 i7-8750H + GTX1066 的板子岂不是更贵?再加上聊起这个事的时候隔壁还说他的一个华硕超极本进水炸了,送修 3500,感觉更凉了。
不过,如果就这么放弃了,手上这 8000+ 的笔记本也太亏了,于是最后的救命稻草还是找 Dell 的官方售后碰碰运气,这玩意估计是保外维修了,接下来做的事情就是平衡下维修机器的价格和买新机的价格选择折中的方案。
于是第三天打开 Dell 官网找了下几个比较靠谱的售后点,说明了下情况,那边也没有拒绝维修。不过由于是工作日询问,在最后提出能不能快递寄修时,对方回答只能亲自送过去,考虑到是工作日,而且偏僻的滨江区无论去哪个维修点都远,只好作罢。直到最后一个售后点在听到要求后,反问了一句【是从哪里寄过来的】,答复在杭州本市后说勉强可以接收,然后说按照描述这个情况比较糟糕,最差的情况可能要和工厂走 ¥1688 的主板交换。一听这价格,卧槽太便宜了,不到两千,比买新机还便宜,立刻寄修。
后续情况可以说运气不错,帅哥说先帮忙清理掉硅橡胶,发现除了那个电阻外还掉了两个电感,说了一通现状和回访时如何回复,然后说先试着走下官方售后质保,如果顺利的话只需要等厂家送新主板就好。过了一个星期说主板送回来了,上机测试没什么问题就先结单了。收到货后开机发现可以正常点亮,不过手头没有带拆掉的 SSD 所以没做进一步的测试。
于是拿回住所拆机,清理掉原厂垃圾硅脂后换上新硅脂和 SSD,然后开机——诶,怎么进不去系统?有了之前折腾灵车 NAS 的经验,进入高级启动模式,关闭故障自动重启,结果发现是蓝屏了。蓝屏代码指向没有启动盘,于是熟练的进入 PE,打开 BOOTICE,修改引导项,重启——诶,怎么还是蓝屏?而且蓝屏代码还变了,这就非常尴尬了,不会运气这么差吧?
再次进入高级启动模式,进入安全模式,正常引导进入系统。打开 BlueScreenView,蓝屏代码指向 nvlddmkm.sys……额,看起来是 N 卡驱动问题。难道换了个主板驱动还能挂的吗?于是打开 DDU 卸载 N 卡驱动,然后重新下载了一个 Nvidia 官网的最新驱动,就在等待驱动安装时……蓝屏了,SYSTEM_THREAD_EXCEPTION_NOT_HANDLED,错误位置 nvlddmkm.sys……花了一听折腾了几个版本的驱动,包括 DCH 驱动、Dell 官网驱动和微软官方驱动,都挂在了安装一半的位置,或者安装成功了,但是在成功的那一刻挂了,最后变成了【由于该设备存在问题,Windows 已停止使用该设备】。
感觉有点不妙,问了下授权维修中心的帅哥,那边说当时上机没遇到问题才结单的,让先试试看重装系统再安装驱动试试。于是接下来继续用上折腾灵车 NAS 的经验,在 SSD 上创建一个 vhdx 虚拟磁盘,diskpart 挂载后 dism 写入 Windows 10 1909 镜像,再用 bcdboot 写入 UEFI 启动项,重启。开机时成功显示了标记为 VHDX 的【Windows 10 1909 (test)】启动项,进入系统,首次开机初始化,成功进入系统。然后满心欢喜地打开设备管理器,结果看到了熟悉的黄色感叹号……再试试安装驱动吧,于是再次打开 N 卡驱动安装程序,然后果不其然地蓝屏了,仍旧是 nvlddmkm.sys。
运气有点背,于是只好送到售后服务中心维修。服务点有点偏,不过看起来还挺专业的,桌面摆着一台笔记本,另一个桌子上也摆着台 15 寸的 G3,凳子上还放着一台拆开的行业用机。帅哥开机试了下,发现果然装不上驱动,然后用上之前的测试盘,结果装上驱动也蓝屏了。这就比较惨了,折腾了一个小时,还看了下主板上的元件也没什么异常,很无奈,说因为之前的单已经结了,要不然还可以再继续申请维修,而单子关了再申请会影响绩效,所以只能等一周再重新申请了。【这种情况比较麻烦,指不定是静电击穿导致的,只能先屏蔽独显了。下次我帮你把硅脂换好好了,信越的也不一定好的】,然后拍出了一管 TFX……
拿回来后开机用了一会后,想起来要屏蔽独显,于是打开设备管理器,结果发现黄色感叹号消失了?????由于这个笔记本的 HDMI 输出是连接的 N 卡而不是核显,于是尝试接上外接显示器,结果还是蓝屏了。但是蓝屏重启后,显卡奇迹般的好了,没有遇到蓝屏,外接显示器可以正常输出显示。难道就这么好了?于是打开 Furmark 开始烤机,结果烤机了 20 分钟没什么事,显卡温度稳定在 70 度,看来运气不错,问题解决了——才怪,接下来玩游戏时画面突然开始卡住,输入无响应。于是强行拔下外接显示器的线,打开任务管理器,发现系统中断 CPU 占用 100%,然后……蓝屏了,VIDEO_SCHEDULER_INTERNAL_ERROR,错误位置 nvlddmkm.sys。额,看来不是省油的灯啊。
在接下来的一个星期里,发现这电脑的蓝屏实在是越发玄学。首先高负载可能不会蓝屏,甚至高负载蓝屏的概率还更低,不如说闲置时反而更容易蓝屏。比如你可以玩上一个小时的炼金工房(GPU 负载 > 80%),但是会在观看视频时突然崩溃(GPU 负载 < 20%),而且崩溃一次后后续崩溃频率会更高。跑 benchmark 也能正常通过,极限的 8K 渲染吃满显存的测试也没问题,说明显存应该也没问题。总之……蓝屏原因很随机,出现的蓝屏代码包括但不限于 VIDEO_TDR_FAILED、VIDEO_SCHEDULER_INTERNAL_ERROR、DRIVER_IRQL_NOT_LESS_OR_EQUAL,一些游戏还会报 0x887A0005、_ERROR_DEVICE_REMOVED、内容不能为 read 等,崩溃时 GPU 使用率瞬间升高等。
总之就这样过了迷惑的一周,最后再次送修,又过了快一周换上了另一块主板,问题终于成功解决了。另外看起来硅脂也帮忙换好了,烤机 GPU 稳定 70 度,CPU 用 XTU 降压 -0.120V 也能保持双烤 2.6GHz 左右,单烤只撞功耗墙不撞温度墙,至少不会变成 1.6GHz 了。最后再参考 https://nota.moe/2018/dell-g3-power-limit/ 安装旧版本 XTU 并配置任务计划定时拉高 PL1,最后可以在烤机下保持 45W 的功耗,至少 CPU 的成绩没那么难看了。就是……PCH 过热的问题依然存在,不得已只能靠上一任住户留下的笔记本散热器吹 PCH 了 = = 总之算上手工清理硅橡胶的费用和来回路费总共花了不到 300,还是比较满意的,售后服务的操作非常厉害,而且售后中心的帅哥看起来技术能力也不错的样子,一改电脑城里那种奸商嘴脸的固有印象,~~总之我再也不黑傻多戴了~~。
在修电脑期间正值双十一,虽然确实考虑买台式机组装,不过考虑到维修价格成本更低,于是后来又都退了,只能说感谢双十一 = = 不过硬盘其实一直告急,于是考虑还是买个硬盘好了。逛了一圈京东,本来打算买个之前已经入手的东芝 DT01ACA300,之前算了下应该是常见容量里单价最便宜的。结果发现西数的 4TB 硬盘价格跌到 600 左右了,算下来单价更便宜,就是要从 7200rpm 变成 5400rpm 了,不过反正局域网只有 1000Mbps,也算够用了。于是最后在双十一高位入手了西数的 HC510,也就是贴牌的 HGST 的 HUH721010ALE600……诶,好像有什么不对?
总之确实如之前贴吧所说,HGST 已经没了,新的硬盘已经变成 WD 的标了,型号也变成了 HC510,虽然 CrystalDiskInfo 和 HDTune Pro 显示还是 HGST,型号也是 HUH721010ALE600。嗯,你能夺走我的人,但是夺不走我的心……好像不对
顺序读写最高 250MB/s,估计这就是外圈速度了,不过放在 NAS 里没有 2.5Gbps/10Gbps 的网络也没什么意义。不过比较特殊的是这盘的 4K 读取虽然继承了机械硬盘的特性:慢,但是 4K 写入却意外的高不少,延迟非常低,CrystalDiskInfo 6 与 7 下 4K 写入均能达到 1500 IOPS,不知道是不是写入到缓存里了。
下面是各项性能:
参考下之前提到的东芝的 3TB 盘,据说是日立生产线,可以看到一般机械硬盘的 4K 都很垃圾,写入基本和读取一样,HGST 的这块氦气盘 4K 写入真的是有点神奇
或者之前的测速,一个东芝 3T 一个希捷 3T,都是 7200 转 PMR(或者说 CMR)
或者之前提到的 HGST 7200 转笔记本 1TB 盘,下图分别是内圈和外圈速度(鬼知道那个 300MB/s 是怎么来的,HGST 的磁盘速度有点玄学)
目前看起来还挺满意的,购入当天就转移和下载了 3TB 的数据进去,看起来还能再用个两三年的……吧,连续运行未关机运行了一个半月,就在圣诞节那两天,这盘居然分区表炸了 = = 说好的企业级呢?用 DiskGenius 修复了分区表,数据应该都还在,由于磁盘实在太大不太想扫坏道,希望没什么大问题吧……没钱组 RAID1
由于某些原因,这个季度大概看了不少来自云音乐下的评论,发现评论区还真是大型复读机垃圾场 = = 虽然估计有人看了 A 岛的那个《网易云评论尴尬串》(原串:https://adnmb.com/t/18885439 ,原微博:https://m.weibo.cn/detail/4454832016040386 ,长图:https://wx2.sinaimg.cn/large/006ahuzrly1gadji1pkwsj30jscml4r1.jpg),不过请放心,看到的评论还没那么可怕,基本上都是这个水平的:https://adnmb2.com/t/18885439?r=22398498
总结一下,基本上就是如下几种类型:
下面是一些评论摘录……
- 你是风儿我是沙 你是插槽我是插
- 做梦,梦见女朋友死了,伤心的哭了。后来发现,我根本没有女朋友,哭得更厉害了。
- 活着,在这个世界上拖着痛苦的自我
- 活着,没有任何幸福
- 我都听哭了,我女朋友没哭,我把她打哭了
- 家里没有音响也没有低音炮,拿着村里的喇叭试了一下猪现在还在抖确实刺激
- 女人错过了她最想嫁的男人,就会变得挑剔,男人错过了他最想娶的女人,就会变得随意。
- 听到这个音乐,我一脚踢开了我家的牛,自己犁了二十多亩地。
- 小罗浩,滴滴地吹
- 推销日本西瓜
- 这么多希尔薇来的啊
- b站希尔薇女儿MMD前来 1
- 有一个MAD是希尔薇唱的
- 这歌真小 不对 这胸真好听 也不对
- 小时候从三格楼梯一跃而下就能获得快乐,现在需要从三十楼。
- 侵犯幼儿缘 请点左上角 排队 196干杯
- 我想做你的猫 不行 做我的猫得绝育
- 发着牢废,说着骚话
不过当我拿着 A 岛上那超长的尬评列表给一个后端的同事看时,他说,这有什么好尴尬的,都挺正常的。嗯,大概这就是代沟吧。他还说他选那些抖机灵的评论还比较容易过,嗯,这么一说我开始期待后续大家的风评了。
贴吧今年 16 岁的,虽然几乎不在贴吧发言了,不过还是凑热闹看了下贴吧的周年统计。嗯,总之既然和贴吧同一天生日,估摸着要和贴吧一起凉凉了。
额,其实说实话,这个季度以来用的比较多的音乐播放工具主要是 foobar2000、SoundCloud、Spotify 和 YouTube,云音乐反而用的不是很多,所以年终盘点的数据还真是一般(嗯,不是二刺螈实在是非常抱歉了
嗯,如果萌否没有完全关站的话,或者至少保留 API 服务器的话,或许还能再加个萌否电台,因为年初的时候写了个 反向代理 请求 nyan.90g.org 的真实服务器并缓存。毕竟,听惯了收藏的歌曲还是想听听很久以前收藏的曲目和完全随机的曲目,以及还是怀念那 64Kbps + 22050Hz 的高压缩音频。结果大概在 4 月左右萌否全站下线了,由于当时缓存的时候没有顺带记录 meta 信息,现在只留下大约 8 GB 的没有歌曲名的音乐文件。嗯,只能说保守估计会有个 80% 的收藏曲目吧,尝试给园长发了封邮件也没有回复,大概这就是青春最后的记忆了吧。
年度歌曲……额,很难说啊,fb2k 上这几个播放列表基本都轮了一遍(IOSYS 的广播剧……听不懂还是算了吧,倒是居然在某个漏尿广播剧里听到了《义勇军进行曲》的 bgm = =),要说年度歌曲还很难说,只能说挑几首最近正在放的曲子吧
(以下内容可能不定期更新)
剩下的塞个 playlist 好了,太多了随便塞点好了(比如土嗨泉、I’ve、KOKIA 已经多到不想一个个挑出来了,Sound Horizon 一些没版权……)
开场紧急抓拍!
是胖次! 可惜只有这个角色能穿模
wwwwwwwwww
Cloudcone 已经沦为鸡场了,干扰严重,最差丢包率 > 60%,甚至不如 Oracle Cloud 日本,只能偶尔靠 CloudFlare 续命(还被 Google 定位 IP 地址来自菲律宾……)
最晚打卡记录,液!(然而大家都知道我厂不记录下班打卡时间
搬家 吸甲醛
以及翻到的迷之 commit 记录 已经凉的差不多了
还写了个迷之 metadata 数据库 https://イー変態.ロリ.みんな ,SQL 语句尝试优化了不少,但是翻页还是很慢,是不是要上 ES 了
Just another E-Hentai metadata database
嗯,总之明年见啦——不对,写完的时候明年已经到了,那么在这里祝各位新年快乐啦~~
]]>Title image of Streams API by Mozilla Contributors is licensed under CC-BY-SA 2.5.
此文章的略微润色版可前往 知乎、掘金、SegmentFault、专栏 查看
在服务端开发中,流是一个很常见的概念。有了流我们就不再需要等待整个数据获取完毕后才处理数据,而是可以一段一段地拿到数据,在获得数据的同时直接解析数据。这样既可以高效利用 CPU 等资源,还减少了存放整个数据的内存占用。不过在过去,客户端 JavaScript 上都没有流的概念,而随着 Streams API 在各大浏览器上的逐步实现,我们终于可以使用原生的 API 以流的角度来看待数据了,例如从 fetch 请求上可以得到一个网络流。
既然标题是「从 Fetch 到 Streams」,那么首先让我们来看看既熟悉又陌生的 Fetch API。相比较于 XMLHttpRequest
,fetch()
的写法简单又直观,只要在发起请求时直接将整个配置项传入就可以了。fetch()
方法接受一个 Request
实例,或者是大家更常使用的方法——传入需要请求的 URL 以及一个可选的初始化配置项对象,然后就可以从 Promise
中取得返回的数据:
fetch('https://example.org/foo', { method: 'POST', mode: 'cors', headers: { 'content-type': 'application/json' }, credentials: 'include', redirect: 'follow', body: JSON.stringify({ foo: 'bar' }) }).then(res => res.json()).then(...)
如果你不喜欢 Promise 的链式调用的话,还可以用 async
/await
:
const res = await fetch('https://example.org/foo', { ... }); const data = await req.json();
看起来相当的直观,在请求时直接将所有的参数传入 fetch()
方法即可,甚至相较于 XHR 还提供了更多的控制参数,例如是否携带 Cookie、是否需要手动跳转等;取出数据也只需要调用 Response
对象上的方法就能拿到格式化的数据(例如 res.json()
)。而直接使用 XMLHttpRequest
看起来就没那么方便了,初始化时既有方法的调用又有参数的赋值,看着还挺混乱的:
const xhr = new XMLHttpRequest(); xhr.open('POST', 'https://example.org/foo'); xhr.setRequestHeader('Content-Type', 'application/json'); xhr.responseType = 'json'; xhr.withCredentials = true; xhr.addEventListener('load', () => { const data = xhr.response; // ... }); xhr.send(JSON.stringify({ foo: 'bar' }))
而随着 AbortController
与 AbortSignal
在各大浏览器上完整实现,Fetch API 也能像 XHR 那样中断一个请求了,只是稍微绕了一点。通过创建一个 AbortController
实例,我们就得到了一个可以控制中断的控制器。这个实例的 signal
参数提供了一个 AbortSignal
实例,还提供了一个 abort()
方法用于发送中断信号。我们将 signal
传递进 fetch()
的初始化参数中,就可以在 fetch 请求之外控制请求的中断了:
const controller = new AbortController(); const { signal } = controller; fetch('/foo', { signal }).then(...); signal.onabort = () => { ... }; controller.abort();
只可惜提出这个解决方案并实装的时间还是有点晚,Fetch API 早在 就在 Firefox 上实现,并且最早于 在 Chrome 上实装。而该功能最早也是 Edge 浏览器在 2017 年 4 月实现,Safari 直到 2018 年末才被发现 AbortController
不能中断请求的 bug,最后在 2019 年 3 月的 Safari 12.1 中才正式解决。算下来从 Fetch API 在浏览器上实装开始,到主流现代浏览器全部支持,跨越了整整四年。
Image of AbortController & AbortSignal Support Table by caniuse.com is licensed under CC-BY 4.0.
晚归晚,但是看起来现在的 Fetch API 已经无所不能了,不过在「真香」之前,我们来考虑一个很常见的场景:浏览器需要异步请求一个比较大的文件,由于可能比较耗时,希望在下载文件时展示文件的下载进度。XHR 提供了很多的事件,其中就包括了传输进度的 onprogress
事件,所以使用 XHR 可以很方便地实现这个功能:
const xhr = new XMLHttpRequest(); xhr.open('GET', '/foo'); xhr.addEventListener('progress', (event) => { const { lengthComputable, loaded, total } = event; if (lengthComputable) { console.log(`Downloaded ${loaded} of ${total} (${(loaded / total * 100).toFixed(2)}%)`); } else { console.log(`Downloaded ${loaded}`); } }); xhr.send();
但是 Fetch API 呢?你打开了 MDN,仔细地看了 fetch()
方法的所有参数,都没有找到类似 progress
这样的参数,毕竟 Fetch API 并没有什么回调事件。难道 Fetch API 就不能实现这么简单的功能吗?这里就要提到和它相关的 Streams API 了——不是 Web Socket,也不是 Media Stream,更不是只能在 Node.js 上使用的 Stream,不过和它很像。
对于非 Web 前端的同学来说,流应该是个很常见的概念,它允许我们一段一段地接收与处理数据。相比较于获取整个数据再处理,流不仅不需要占用一大块内存空间来存放整个数据,节省内存占用空间,而且还能实时地对数据进行处理,不需要等待整个数据获取完毕,从而缩短整个操作的耗时。
此外流还有管道的概念,我们可以封装一些类似中间件的中间流,用管道将各个流连接起来,在管道的末端就能拿到处理后的数据。例如,下面的这段 Node.js 代码片段实现了解压 zip 中的文件的功能,只需要从 zip 的中央文件记录表中读取出各个文件在 zip 文件内的起止偏移值,就能将对应的文件解压出来。
const src = fs.createReadStream(null, { fd, start, end, autoClose: false }); const dest = fs.createWriteStream(outputPath + name); // 从流中直接读取数据 src.on('data', (chunk) => { ... }); // 或者直接将流引向另一个流 if (compressed) { src.pipe(zlib.createInflateRaw()).pipe(dest); } else { src.pipe(dest); }
其中的 input
是一个可读取的流,output
是一个可写入的流,而 zlib.createInflateRaw()
就是创建了一个既可读取又可写入的流,它在写入端以流的形式接受 Deflate 压缩的数据,在读取端以流的形式输出解压缩后的数据。我们想象一下,如果输入的 zip 文件是一个上 GB 的大文件,使用流的方式就不再需要占用同样大小的上 GB 的内存空间。而且从代码上看,使用流实现的代码逻辑同样简洁和清晰。
很可惜,过去在客户端 JavaScript 上并没有原生的流 API——当然你可以自己封装实现流,比如 JSZip 在 3.0 版本就封装了一个 StreamHelper
,但是基本上除了使用这些 stream 库的库以外,没有其它地方能 产生 兼容这个库的流了。没有能产生流的数据源才是大问题,比如想要读取一个文件?过去 FileReader
只能在 onload
事件上拿到整个文件的数据,或者对文件使用 slice()
方法得到 Blob
文件片段,~~目前也没有原生的 FileReaderStream
之类的 API~~。
现在 Streams API 已经在浏览器上逐步实现(或者说,早在 2016 年 Chrome 就开始支持一部分功能了),能用上流处理的 API 想必也会越来越多,而 Streams API 最早的受益者之一就是 Fetch API。
Streams API 赋予了网络请求以片段处理数据的能力,过去我们使用 XMLHttpRequest
获取一个文件时,我们必须等待浏览器下载完整的文件,等待浏览器处理成我们需要的格式,收到所有的数据后才能处理它。现在有了流,我们可以以 TypedArray
片段的形式接收一部分二进制数据,然后直接对数据进行处理,这就有点像是浏览器内部接收并处理数据的逻辑。甚至我们可以将一些操作以流的形式封装,再用管道把多个流连接起来,管道的另一端就是最终处理好的数据。
Fetch API 会在发起请求后得到的 Promise 对象中返回一个 Response
对象,而 Response
对象除了提供 headers
、redirect()
等参数和方法外,还实现了 Body
这个 mixin 类,而在 Body
上我们才看到我们常用的那些 res.json()
、res.text()
、res.arrayBuffer()
等方法。在 Body
上还有一个 body
参数,这个 body
参数就是一个 ReadableStream
。
既然本文是从 Fetch API 的角度出发,而如前所述,能产生数据的数据源才是流处理中最重要的一个部分,那么下面我们来重点了解下这个在 Body
中负责提供数据的 ReadableStream
。
这篇文章不会讨论流的排队策略(如
highWaterMark
),也不会讨论没有浏览器实现的 BYOR reader,感兴趣的同学可以参考相关规范文档
ReadableStream
The image of ReadableStream Concept by Mozilla Contributors is licensed under CC-BY-SA 2.5.
下面是一个 ReadableStream
实例上的参数和可以使用的方法,下文我们将会详细介绍它们:
ReadableStream
– locked
– cancel()
– pipeThrough()
– pipeTo()
– tee()
– getReader()
其中直接调用 getReader()
方法会得到一个 ReadableStreamDefaultReader
实例,通过这个实例我们就能读取 ReadableStream
上的数据。
ReadableStream
中读取数据ReadableStreamDefaultReader
实例上提供了如下的方法:
ReadableStreamDefaultReader
– closed
– cancel()
– read()
– releaseLock()
假设我们需要读取一个流中的的数据,可以循环调用 reader 的 read()
方法,它会返回一个 Promise
对象,在 Promise
中返回一个包含 value
参数和 done
参数的对象。
const reader = stream.getReader(); let bytesReceived = 0; const processData = (result) => { // Result objects contain two properties: // done - true if the stream has already given all its data. // value - some data. Always undefined when done is true. if (result.done) { console.log(`complete, total size: ${bytesReceived}`); return; } // value for fetch streams is a Uint8Array const value = result.value; const length = value.length; console.log(`got ${length} bytes data:`, value); bytesReceived += length; // Read some more, and call this function again return reader.read().then(processData); }; reader.read().then(processData);
其中 result.value
参数为这次读取得到的片段,它是一个 Uint8Array
,通过循环调用 reader.read()
方法就能一点点地获取流的整个数据;而 result.done
参数负责表明这个流是否已经读取完毕,当 result.done
为 true
时表明流已经关闭,不会再有新的数据,此时 result.value
的值为 undefined
。
现在我们已经知道 Fetch API 返回的 Response
对象可以拿到一个对应的 ReadableStream
,以及如何从一个 ReadableStream
中得到数据了。那么回到我们之前的问题,我们可以通过读取 Response
中的流得到目前接收的片段,累加各个 Uint8Array
片段的 length
属性就能得到类似 XHR onprogress
事件的 loaded
;通过从 Response
的 headers
中取出 Content-Length
属性就能得到类似 XHR onprogress
事件的 total
。于是我们可以写出下面的代码,成功得到下载进度:
let total = null; let loaded = 0; const logProgress = (reader) => { return reader.read().then(({ value, done }) => { if (done) { console.log('Download completed'); return; } loaded += value.length; if (total === null) { console.log(`Downloaded ${loaded}`); } else { console.log(`Downloaded ${loaded} of ${total} (${(loaded / total * 100).toFixed(2)}%)`); } return logProgress(reader); }); }; fetch('/foo').then((res) => { total = res.headers.get('content-length'); return res.body.getReader(); }).then(logProgress);
看着好像没问题是吧?问题来了,数据呢?我那么大一个返回数据呢?上面的代码只顾着输出进度了,结果并没有把返回数据传回来。虽然我们可以直接在上面的代码里处理二进制数据片段,可是有时我们还是会偷懒,直接得到完整的数据进行处理(比如一个巨大的 JSON 字符串)。
如果我们希望接收的数据是文本,一种解决方案是拼接得到的 Uint8Array
并返回,然后再使用 TextDecoder
等方法得到解析后的文本:
let chunk = new Uint8Array(0); const logProcess = (res) => { const reader = res.body.getReader(); const push = ({ value, done }) => { if (done) return chunk; // chunk is an Uint8Array const newChunk = new Uint8Array(chunk.length + value.length); newChunk.set(chunk, 0); newChunk.set(value, chunk.length); chunk = newChunk; // ... return reader.read().then(push); }; return reader.read().then(push); }; fetch('/foo').then(logProgress).then((res) => { const decoder = new TextDecoder('utf-8'); const text = decoder.decode(res); const data = JSON.parse(res); // ... });
或者我们可以在读取片段时就解析文本,最终直接返回解析后的整个文本:
let text = ''; const logProcess = (res) => { const reader = res.body.getReader(); const decoder = new TextDecoder('utf-8'); const push = ({ value, done }) => { if (done) return JSON.parse(text); text += decoder.decode(value, { stream: true }); // ... return reader.read().then(push); }; return reader.read().then(push); }; fetch('/foo').then(logProgress).then((res) => { ... });
不过如果你犯了强迫症,一定要像原来那样显示调用 res.json()
之类的方法得到数据,这该怎么办呢?既然 fetch()
方法返回一个 Response
对象,而这个对象的数据已经在 ReadableStream
中读取下载进度时被使用了,那我再造一个 ReadableStream
外面再包一个 Response
对象,问题不就解决了吗?
ReadableStream
构造一个 ReadableStream
时可以定义以下方法和参数:
const stream = new ReadableStream({ start(controller) { // called immediately when the object is constructed controller.desiredSize // the desired size required to fill the queue controller.close() // close the stream controller.enqueue(chunk) // enqueue a given chunk in the stream controller.error(reason) // cause an error to stream }, pull(controller) { // called repeatedly when the stream's internal queue of chunks // is not full, up until it reaches its high water mark }, cancel(reason) { // called if it signals that the stream is to be cancelled } }, queueingStrategy); // { highWaterMark: 1 }
而构造一个 Response
对象就简单了,Response
对象的第一个参数即是返回值,可以是字符串、Blob
、TypedArray
,甚至是一个 Stream
;而它的第二个参数则和 fetch()
方法很像,也是一些初始化参数。
const response = new Response(source, init);
了解以上的内容后,我们只需要构造一个 ReadableStream
,然后把「从 reader 中循环读取数据」的逻辑放在这个流的 start()
方法内,它会在流实例化后立即调用。当 reader 读取数据时可以输出下载进度,同时调用 controller.enqueue()
把得到的数据推进我们构造出来的流,最后在读取完毕时调用 controller.close()
关闭这个流,问题就能轻松解决。
const logProgress = (res) => { const total = res.headers.get('content-length'); let loaded = 0; const reader = res.body.getReader(); const stream = new ReadableStream({ start(controller) { const push = () => { reader.read().then(({ value, done }) => { if (done) { controller.close(); return; } loaded += value.length; if (total === null) { console.log(`Downloaded ${loaded}`); } else { console.log(`Downloaded ${loaded} of ${total} (${(loaded / total * 100).toFixed(2)}%)`); } controller.enqueue(value); push(); }); }; push(); } }); return new Response(stream, { headers: res.headers }); }; fetch('/foo').then(logProgress).then(res => res.json()).then((data) => { ... });
ReadableStream
感觉是不是绕了一个远路?就为了这点功能我们居然构造了一个 ReadableStream
实例?有没有更简单的方法?其实是有的,如果你稍有留意的话,应该会注意到 ReadableStream
实例上有一个名字看起来有点奇怪的 tee()
方法。这个方法可以将一个流分流成两个一模一样的流,两个流可以读取完全相同的数据。
The image of Teeing a ReadableStream by Mozilla Contributors is licensed under CC-BY-SA 2.5.
所以我们可以利用这个特性将一个流分成两个流,将其中一个流用于输出下载进度,而另一个流直接返回:
const logProgress = (res) => { const total = res.headers.get('content-length'); let loaded = 0; const [progressStream, returnStream] = res.body.tee(); const reader = progressStream.getReader(); const log = () => { reader.read().then(({ value, done }) => { if (done) return; // 省略输出进度 log(); }); }; log(); return new Response(returnStream, { headers: res.headers }); }; fetch('/foo').then(logProgress).then(res => res.json()).then((data) => { ... });
可是这样我们还是构造了一个新的 Response
实例,有没有更简单的方法?对不起,其实……还是有的,如果你再稍有留意的话,应该会注意到 Response
实例上有一个一看就知道是什么意思的 clone()
方法。这个方法可以得到一个克隆的 Response
实例(其实这个方法在 Service Worker 里用作请求缓存的情况比较多),所以我们可以将其中一个实例用来获取流并得到下载进度,另一个实例直接返回:
const logProgress = (res) => { const total = res.headers.get('content-length'); let loaded = 0; const clonedRes = res.clone(); const reader = clonedRes.body.getReader(); const log = () => { reader.read().then(({ value, done }) => { if (done) return; // 省略输出进度 log(); }); }; log(); return res; }; fetch('/foo').then(logProgress).then(res => res.json()).then((data) => { ... });
课后习题 Q1:如果我们调用了流的
tee()
方法得到了两个流,但我们只读取了其中一个流,另一个流在之后读取,会发生什么吗?
很好,下载进度的问题完美解决了,那么让我们回到最早的问题。Fetch API 最早是没有 signal
这个参数的,所以早期的 fetch 请求很难中断——对,是「很难」,而不是「不可能」。如果浏览器实现了 ReadableStream
并在 Response
上提供了 body
的话,是可以通过流的中断实现这个功能的。
ReadableStream
总结一下我们现在已经知道的内容,fetch 请求返回一个 Response
对象,从中可以得到一个 ReadableStream
,然后我们还知道了如何自己构造 ReadableStream
和 Response
对象。再回过头看看 ReadableStream
实例上还没提到的方法,想必你一定注意到了那个 cancel()
方法。
通过 ReadableStream
上的 cancel()
方法,我们可以关闭这个流。此外你可能也注意到 reader 上也有一个 cancel()
方法,这个方法的作用是关闭与这个 reader 相关联的流,所以从结果上来看,两者是一样的。而对于 Fetch API 来说,关闭返回的 Response
对象的流的结果就相当于中断了这个请求。
所以,我们可以像之前那样构造一个 ReadableStream
用于传递从 res.body.getReader()
中得到的数据,并对外暴露一个 aborter()
方法。调用这个 aborter()
方法时会调用 reader.cancel()
关闭 fetch 请求返回的流,然后调用 controller.error()
抛出错误,中断构造出来的传递给后续操作的流:
let aborter = null; const abortHandler = (res) => { const reader = res.body.getReader(); const stream = new ReadableStream({ start(controller) { let aborted = false; const push = () => { reader.read().then(({ value, done }) => { if (done) { if (!aborted) controller.close(); return; } controller.enqueue(value); push(); }); }; aborter = () => { reader.cancel(); controller.error(new Error('Fetch aborted')); aborted = true; }; push(); } }); return new Response(stream, { headers: res.headers }); }; fetch('/foo').then(abortHandler).then(res => res.json()).then((data) => { ... }); aborter();
课后习题 Q2:从上面的结果来看,当我们调用
aborter()
方法时,请求被成功中止了。不过如果不调用controller.error()
抛出错误强制中断流,而是继续之前的流程调用controller.close()
关闭流,会发生什么事吗?
或许你还是很奇怪,既然流本身就有一个 cancel()
方法,为什么我们不直接暴露这个方法,反而要绕路构造一个新的 ReadableStream
呢?
let aborter = null; const abortHandler = (res) => { aborter = () => res.body.cancel(); return res; }; fetch('/foo').then(abortHandler).then(res => res.json()).then((data) => { ... }); aborter();
可惜这样执行会得到下面的错误:
TypeError: Failed to execute 'cancel' on 'ReadableStream': Cannot cancel a locked stream
什么?流被锁了?你不信邪,既然流的 reader 被关闭时会关闭相关联的流,那么只要再获取一个 reader 并 cancel()
不就好了?
let aborter = null; const abortHandler = (res) => { aborter = () => res.body.getReader().cancel(); return res; }; fetch('/foo').then(abortHandler).then(res => res.json()).then((data) => { ... }); aborter();
可惜这样执行还是会得到下面的错误:
TypeError: Failed to execute 'getReader' on 'ReadableStream': ReadableStreamReader constructor can only accept readable streams that are not yet locked to a reader
或许你还会想,像之前那样克隆一个流,然后关闭克隆的流不就好了?可惜被使用的流是不能被克隆的,即便在使用前先克隆一个流,你也会发现即便成功调用了 close()
方法,请求还是没有中断,因为另一个流并没有被中断,还是可以接收数据的。
于是我们第一次接触到了流的锁机制。一个流只能同时有一个活跃的 reader,当一个流被一个 reader 使用时,这个流就被该 reader 锁定了,此时流的 locked
属性为 true
。如果这个流需要被另一个 reader 读取,那么当前活跃的 reader 可以调用 reader.releaseLock()
方法释放锁。
此外 reader 的 closed
属性是一个 Promise,当 reader 被关闭或者释放锁时,这个 Promise 会被 resolve,可以在这里编写关闭 reader 的处理逻辑:
reader.closed.then(() => { console.log('reader closed'); }); reader.releaseLock();
可是上面的代码似乎没用上 reader 啊?再仔细思考下 res => res.json()
这段代码,是不是有什么启发?
让我们翻一下 Fetch API 的规范文档,在 5.2. Body mixin 中有如下一段话:
Objects implementing the
Body
mixin also have an associated consume body algorithm, given a type, runs these steps:
- If this object is disturbed or locked, return a new promise rejected with a
TypeError
.Let stream be body’s stream if body is non-null, or an empty
ReadableStream
object otherwise.Let reader be the result of getting a reader from stream. If that threw an exception, return a new promise rejected with that exception.
Let promise be the result of reading all bytes from stream with reader.
Return the result of transforming promise by a fulfillment handler that returns the result of the package data algorithm with its first argument, type and this object’s MIME type.
简单来说,当我们调用 Body
上的方法时,浏览器隐式地创建了一个 reader 读取了返回数据的流,并创建了一个 Promise
实例,待所有数据被读取完后再 resolve 并返回格式化后的数据。所以,当我们调用了 Body
上的方法时,其实就创建了一个我们无法接触到的 reader,此时这个流就被锁住了,自然也无法从外部取消。
现在我们可以随时中断一个请求,以及获取到请求过程中的数据,甚至还能修改这些数据。或许我们可以用来做些有趣的事情,比如各个下载器中非常流行的断点续传功能。
首先我们先来了解下断点续传的原理,简述如下:
Content-Length
属性Range
请求头在过去只能使用 XMLHttpRequest
或者还没有 Stream API 的时候,我们只能在请求完成时拿到数据。如果期间请求中断了,那也不会得到已经下载的数据,也就是这部分请求的流量被浪费了。所以断点续传最大的问题是获取已拿到的数据,也就是上面的第 3 步,根据已拿到的数据就能算出还有哪些数据需要请求。
其实在 Streams API 诞生之前,大家已经有着各种各样奇怪的方式实现断点续传了。来自新西兰的 Mega 网盘可以说是最佳 webapp 之一了,且不提它的加密功能,在浏览器中下载文件时它不会立即让浏览器下载文件,而是将数据放在浏览器内,待所有数据下载完成后再让浏览器下载文件,而这样的特性让 Mega 网盘可以实现断点续传的功能。不过刚刚不是提到 XHR 是不支持拿到请求中断时的数据吗?那么这个功能是怎么实现的?
仔细观察网络请求就会发现,Mega 在下载时不是下载整个文件,而是在请求的 URL 中包含了一个 Range 参数,表明需要拉取文件的哪一个片段。所以 Mega 是通过建立多个小的请求获取文件的各个小区块,待下载完成后再拼接为一个大文件。即便用户中途暂停,已下载的块也不会丢失,继续下载时会重新请求未完成的区块。不过这样会频繁建立多个 HTTP 请求(当然一般是 keep-alive
的,所以应该不用考虑重建 TCP 连接),而且其实在暂停时,正在下载的区块还是会被丢弃。不过相比较于丢弃整个文件来说,现在的实现已经是很大的优化了。
此节文本可能包含一些业务上的功能逻辑,由于本人并未接触相关代码,仅能从浏览器的请求结果上分析,所以可能存在问题
除了主动获取小区块变相实现断点续传外,其实 Firefox 浏览器上的私有特性允许开发者获取正在下载的文件片段,而云音乐就使用了该特性优化了 Firefox 浏览器上的音频文件请求。
Firefox 浏览器的 XMLHttpRequest
为 responseType
属性提供了私有的可用参数 moz-chunked-arraybuffer
。请求还未完成时,可以在 progress
事件中请求 XHR 实例的 response
属性,它将会返回目前已经接收到的所有数据,而在 progress
事件外获取该属性将始终是 null
。
let chunks; const xhr = new XMLHttpRequest(); xhr.open('GET', '/foo'); xhr.responseType = 'moz-chunked-arraybuffer'; xhr.addEventListener('progress', (event) => { chunks = xhr.response; }); xhr.addEventListener('abort', () => { const blob = new Blob(chunks); }); xhr.send();
看起来是个很不错的特性,只可惜在 Bugzilla 上某个 和云音乐相关的 issue 里,有人用 Mozilla 的 debug 工具 mozregression 发现这个特性已经在 Firefox 68 中移除了。
原因也可以理解,Firefox 现在已经在 fetch 上实现 Stream API 了,有标准定义当然还是跟着标准走(虽然至今还是 LS 阶段),所以也就不再需要这些私有属性了。
从之前的示例我们已经知道,我们可以从 fetch 请求返回的 ReadableStream
里得到正在下载的数据区块,那么只要在请求的过程中把它们放在一个类似缓冲区的地方就可以实现之前的第 3 步了。请求中断后再次请求时,只需要根据已下载区块的字节数就可以算出接下来要请求哪些区块了。待全部下载完成后,再将已下载的数据拼接返回,就能得到完整的数据了。简单来看,逻辑大概是下面这样:
const chunks = []; let length = 0; const chunkCache = (res) => { const total = res.headers.get('content-length'); const reader = res.body.getReader(); const stream = new ReadableStream({ start(controller) { const push = () => { reader.read().then(({ value, done }) => { if (done) { let chunk; while (chunk = chunks.shift()) { controller.enqueue(chunk); } controller.close(); return; } chunks.push(value); length += value.length; push(); }); }; push(); } }); return new Response(stream, { headers: res.headers }); }; const controller = new AbortController(); fetch('/foo', { headers: { 'Range': `bytes=${length}-` }, signal: controller.signal }).then(chunkCache).then(...); // 请求中断后再次执行上述 fetch() 方法
下面的例子对上述代码简单封装得到了 ResumableFetch
,并使用它实现了图片下载的断点续传。示例完整代码可在 CodePen 上查看。
See the Pen Resumable Fetch Example by ccloli (@ccloli) on CodePen.dark
注意:该示例中的代码仅进行了简单封装,没有做诸如
If-Range
、Range
和Content-Length
等 header 的校验,也没有做特殊的错误处理,也没有包含之前提到的中断请求兼容代码,使用上可能也不够友好,仅供示例使用,请谨慎用于生产环境,未来可能会完善功能后单独发布 npm 包。
封装的 ResumableFetch
类会在请求过程中创建一个 ReadableStream
实例并直接返回,同时已下载的片段将会放进一个数组 chunks
并记录已下载的文件大小 length
。当请求中断并重新下载时会根据已下载的文件大小设置 Range
请求头,此时拿到的就是还未下载的片段。下载完成后再将片段从 chunks
中取出,此时不需要对片段进行处理,只需要逐一传递给 ReadableStream
即可得到完整的文件。
到这里 ReadableStream
上的方法已经描述的差不多了,最后只剩下 pipeTo()
方法和 pipeThrough()
方法没有提到了。从字面意思上来看,这就是我们之前提到的管道,可以将流直接指向另一个流,最后拿到处理后的数据。Jake Archibald 在他的那篇《2016 — 属于 web streams 的一年》中提出了下面的例子,或许在(当时的)未来可以通过这样的形式以流的形式得到解析后的文本:
var reader = response.body .pipeThrough(new TextDecoder()).getReader(); reader.read().then(result => { // result.value will be a string });
现在那个未来已经到了,为了不破坏兼容性,TextEncoder
和 TextDecoder
分别扩展出了新的 TextEncoderStream
和 TextDecoderStream
,允许我们以流的方式编码或者解码文本。例如下面的例子会在请求中检索 It works!
这段文字,当找到这段文字时返回 true
同时断开请求。此时我们不需要再接收后续的数据,可以减少请求的流量:
fetch('/index.html').then((res) => { const decoder = new TextDecoderStream('gbk', { ignoreBOM: true }); const textStream = res.body.pipeThrough(decoder); const reader = textStream.getReader(); const findMatched = () => reader.read().then(({ value, done }) => { if (done) { return false; } if (value.indexOf('It works!') >= 0) { reader.cancel(); return true; } return findMatched(); }); return findMatched(); }).then((isMatched) => { ... });
或者在未来,我们甚至在流里实现实时转码视频并播放,或者将浏览器还不支持的图片以流的形式实时渲染出来:
const encoder = new VideoEncoder({ input: 'gif', output: 'h264' }); const media = new MediaStream(); const video = document.createElement('video'); fetch('/sample.gif').then((res) => { response.body.pipeThrough(encoder).pipeTo(media); video.srcObject = media; });
从中应该可以看出来这两种方法的区别:pipeTo()
方法应该会接受一个可以写入的流,也就是 WritableStream
;而 pipeThrough()
方法应该会接受一个既可写入又可读取的流,也就是 TransformStream
。
The image of Stream Pipe Chains Concept by Mozilla Contributors is licensed under CC-BY-SA 2.5.
接下来我们将介绍这两种流,不过在继续之前,我们先来看看 ReadableStream
在浏览器上的支持程度:
Image of Stream API Browser Compatibilty Table by Mozilla Contributors is licensed under CC-BY-SA 2.5.
从表中我们注意到,这两个方法支持的比较晚。而原因估计你也能猜得到,当数据从一个可读取的流中流出时,管道的另一端应该是一个可写入的流,问题就在于可写入的流实现的比较晚。
WritableStream
The image of WritableStream Concept by Mozilla Contributors is licensed under CC-BY-SA 2.5.
我们已经从 ReadableStream
中了解到很多关于流的知识了,所以下面我们简单过一下 WritableStream
。WritableStream
就是可写入的流,如果说 ReadableStream
是一个流的起点,那么 WritableStream
可以理解为流的终点。下面是一个 WritableStream
实例上的参数和可以使用的方法:
WritableStream
– locked
– abort()
– getWriter()
可用的方法和参数很少,估计大家从名字就能知道它们是做什么的。其中直接调用 getWriter()
方法会得到一个 WritableStreamDefaultWriter
实例,通过这个实例我们就能向 WritableStream
写入数据。同样的,当我们激活了一个 writer 后,这个流就会被锁定(locked = true
)。这个 writer 上有如下属性和方法:
WritableStreamDefaultWriter
– closed
– desiredSize
– ready
– abort()
– close()
– write()
– releaseLock()
看起来和 ReadableStreamDefaultReader
没太大区别,多出的 abort()
方法相当于抛出了一个错误,使这个流不能再被写入。另外这里多出了一个 ready
属性,这个属性是一个 Promise
,当它被 resolve 时,表明目前流的缓冲区队列不再过载,可以安全地写入。所以如果需要循环向一个流写入数据的话,最好放在 ready
处理。
同样的,我们可以自己构造一个 WritableStream
,构造时可以定义以下方法和参数:
const stream = new WritableStream({ start(controller) { // called immediately when the object is constructed controller.error(reason) // cause an error to stream }, write(chunk, controller) { // called when a new chunk of data is ready to be written }, close(controller) { // called if it signals that the stream has finished writing }, abort(reason) { // called if it signals that it wishes to abruptly close the // stream and put it in an errored state } }, queueingStrategy); // { highWaterMark: 1 }
下面的例子中,我们通过循环调用 writer.write()
方法向一个 WritableStream
写入数据:
const stream = new WritableStream({ write(chunk) { return new Promise((resolve) => { console.log('got chunk:', chunk); // ...... resolve(); }); }, close() { console.log('stream closed'); }, abort() { console.log('stream aborted'); } }); const writer = stream.getWriter(); // 将数据逐一写入 stream data.forEach((chunk) => { writer.ready.then(() => { writer.write(chunk); }); }); // 在关闭 writer 前先保证所有的数据已经被写入 writer.ready.then(() => { writer.close(); });
下面是 WritableStream
的浏览器支持情况,可见 WritableStream
在各个浏览器上的的实现时间和 pipeTo()
与 pipeThrough()
方法的实现时间是吻合的,毕竟要有了可写入的流,管道才有存在的意义。
Image of Stream API Browser Compatibilty Table by Mozilla Contributors is licensed under CC-BY-SA 2.5.
TransformStream
从之前的介绍中我们知道,TransformStream
是一个既可写入又可读取的流,正如它的名字一样,它作为一个中间流起着转换的作用。所以一个 TransformStream
实例只有如下参数:
TransformStream
– readable
: ReadableStream
– writable
: WritableStream
TransformStream
上没有其他的方法,它只暴露了自身的 ReadableStream
与 WritableStream
。我们只需要在数据源流上链式使用 pipeThrough()
方法就能实现流的数据传递,或者使用暴露出来的 readable
和 writable
直接操作数据即可使用它。
TransformStream
的处理逻辑主要在流内部实现,下面是构造一个 TransformStream
时可以定义的方法和参数:
const stream = new TransformStream({ start(controller) { // called immediately when the object is constructed controller.desiredSize // the desired size required to fill the queue controller.enqueue(chunk) // enqueue a given chunk in the readable side controller.error(reason) // error both the readable side and the writable side controller.terminate() // close the readable side and error the writable side }, transform(chunk, controller) { // called when a new chunk will be transformed to the writable side }, flush(controller) { // called after all chunks written to the writable side // have been transformed by successfully passing through // transform(), and the writable side is about to be closed } }, queueingStrategy); // { highWaterMark: 1 }
有了 ReadableStream
与 WritableStream
作为前置知识,TransformStream
就不需要做太多介绍了。下面的示例代码摘自 MDN,是一段实现 TextEncoderStream
和 TextDecoderStream
的 polyfill,本质上只是对 TextEncoder
和 TextDecoder
进行了一层封装:
const tes = { start() { this.encoder = new TextEncoder() }, transform(chunk, controller) { controller.enqueue(this.encoder.encode(chunk)) } } let _jstes_wm = new WeakMap(); /* info holder */ class JSTextEncoderStream extends TransformStream { constructor() { let t = { ...tes } super(t) _jstes_wm.set(this, t) } get encoding() { return _jstes_wm.get(this).encoder.encoding } }
const tes = { start() { this.decoder = new TextDecoder(this.encoding, this.options) }, transform(chunk, controller) { controller.enqueue(this.decoder.decode(chunk)) } } let _jstds_wm = new WeakMap(); /* info holder */ class JSTextDecoderStream extends TransformStream { constructor(encoding = 'utf-8', { ...options } = {}) { let t = { ...tds, encoding, options } super(t) _jstes_wm.set(this, t) } get encoding() { return _jstds_wm.get(this).decoder.encoding } get fatal() { return _jstds_wm.get(this).decoder.fatal } get ignoreBOM() { return _jstds_wm.get(this).decoder.ignoreBOM } }
The source code of Polyfilling TextEncoderStream and TextDecoderStream by Mozilla Contributors is licensed under CC-BY-SA 2.5 or CC0.
到这里我们已经把 Streams API 中所提供的流浏览了一遍,最后是 caniuse 上的浏览器支持数据,可见目前 Streams API 的支持度不算太差,至少主流浏览器都支持了 ReadableStream
,读取流已经不是什么问题了,可写入的流使用场景也比较少。不过其实问题不是特别大,我们已经简单知道了流的原理,做一些简单的 polyfill 或者额外写些兼容代码应该也是可以的,毕竟已经有不少第三方实现了。
Image of Streams Support Table by caniuse.com is licensed under CC-BY 4.0.
首先让我们来模拟体验一下龟速到只有大约 30B/s 的网页看起来是什么样子的:
其实这是借助 Service Worker 的 fetch
事件配合 Streams API 实现的。熟悉 Service Worker 的同学应该知道 Service Worker 里有一个 fetch
事件,可以在事件内捕获到页面所有的请求,fetch
事件的事件对象 FetchEvent
中包含如下参数和方法:
addEventListener('fetch', (fetchEvent) => { fetchEvent.clientId // The id of the same-origin client fetchEvent.preloadResponse // A Promise for a preload Response fetchEvent.request // The Request the browser intends to make fetchEvent.replacesClientId // The id of the client that's being replaced // during a page navigation fetchEvent.resultingClientId // The id of the client that replaces the previous // client during a page navigation fetchEvent.respondWith() // Prevent the browser's default fetch handling, // and provide (a promise for) a response yourself fetchEvent.waitUntil() // Extends the lifetime of the event, // like do something beyond the returning });
使用 Service Worker 最常见的例子是借助 fetch
事件实现中间缓存甚至离线缓存。我们可以调用 caches.open()
打开或者创建一个缓存对象 cache
,如果 cache.match(event.request)
有缓存的结果时,可以调用 event.respondWith()
方法直接返回缓存好的数据;如果没有缓存的数据,我们再在 Service Worker 里调用 fetch(event.request)
发出真正的网络请求,请求结束后我们再在 event.waitUntil()
里调用 cache.put(event.request, response.clone())
缓存响应的副本。由此可见,Service Worker 在这之间充当了一个中间人的角色,可以捕获到页面发起的所有请求,然后根据情况返回缓存的请求,所以可以猜到我们甚至可以改变预期的请求,返回另一个请求的返回值。
Streams API 在 Service Worker 中同样可用,所以我们可以在 Service Worker 里监听 fetch
事件,然后用上我们之前学习到的知识,改变 fetch 请求的返回结果为一个速度很缓慢的流。这里我们让这个流每隔约 30 ms 才吐出 1 个字节,最后就能实现上面视频中的效果:
globalThis.addEventListener('fetch', (event) => { event.respondWith((async () => { const response = await fetch(event.request); const { body } = response; const reader = body.getReader(); const stream = new ReadableStream({ start(controller) { const sleep = time => new Promise(resolve => setTimeout(resolve, time)); const pushSlowly = () => { reader.read().then(async ({ value, done }) => { if (done) { controller.close(); return; } const length = value.length; for (let i = 0; i < length; i++) { await sleep(30); controller.enqueue(value.slice(i, i + 1)); } pushSlowly(); }); }; pushSlowly(); } }); return new Response(stream, { headers: response.headers }); })()); });
看着不是很实用?那么再举一个比较实用的例子吧。如果我们需要让用户在浏览器中下载一个文件,一般都是会指向一个服务器上的链接,然后浏览器发起请求从服务器上下载文件。那么如果我们需要让用户下载一个在客户端生成的文件,比如从 canvas 上生成的图像,应该怎么办呢?其实让客户端主动下载文件已经有现成的库 FileSaver.js 实现了,它的原理可以用下面的代码简述:
const a = document.createElement('a'); const blob = new Blob(chunk, options); const url = URL.createObjectURL(blob); a.href = url; a.download = 'filename'; const event = new MouseEvent('click'); a.dispatchEvent(event); setTimeout(() => { URL.revokeObjectURL(url); if (blob.close) blob.close(); }, 1e3);
这里利用了 HTML 标签上的
download
属性,当链接存在该属性时,浏览器会将链接的目标视为一个需要下载的文件,链接不会在浏览器中打开,转而会将链接的内容下载到设备的硬盘上。此外在浏览器中还有 Blob
对象,它相当于一个类似文件的二进制数据对象(File
就是继承于它)。我们可以将需要下载的数据(无论是什么类型,字符串、TypedArray 甚至是其他 Blob
对象)传进 Blob
的构造函数里,这样我们就得到了一个 Blob
对象。最后我们再通过 URL.createObjectURL()
方法可以得到一个 blob:
开头的 Blob URL,将它放到有 download
属性的 链接上,并触发鼠标点击事件,浏览器就能下载对应的数据了。
顺带一提,在最新的 Chrome 76 和 Firefox 69 上,
Blob
实例支持了stream()
方法,它将返回一个ReadableStream
实例。所以现在我们终于可以直接以流的形式读取文件了——看,只要ReadableStream
实现了,相关的原生数据流源也会完善,其他的流或许也只是时间问题而已。
不过问题来了,如果需要下载的文件数据量非常大,比如这个数据是通过 XHR/Fetch 或者 WebRTC 传输得到的,直接生成 Blob 可能会遇到内存不足的问题。甚至 在 Chrome 57 之前,整个浏览器内只能使用最大 500 MB 的共享 Blob Storage。如果超出了可用空间,Chrome 浏览器会返回一个没有任何数据的 Blob 对象,Firefox 浏览器会抛出 NS_ERROR_OUT_OF_MEMORY
错误。再加上更早以前的 Blob
实例上是没有主动释放内存的 close()
方法的,你只能调用 URL.revokeObjectURL()
来去除所有的引用,然后祈祷浏览器的黑盒会在什么时候 GC 掉它。
下面是一个比较极端的糟糕例子,它描述了客户端打包下载图片的流程。客户端 JavaScript 发起多个请求得到多个文件,然后通过 JSZip 这个库生成了一个巨大的 ArrayBuffer
数据,这个数据就是一个 zip 文件。接下来就像之前提到的那样,我们构造了一个 Blob
对象并用 FileSaver.js
下载了这个图片。如你所见,所有的数据都是存放在内存中的,而在生产 zip 文件时,我们又占用了近乎一样大小的内存空间。最后正如下图黄色部分指示的那样一样,这样的实现可能会在浏览器内占用 2-3 倍的内存空间,即便使用 Chrome 私有的 File System API 来缓解 Blob Storage 容量的问题,也无法解决内存占用的问题。
我们之前提到的 Mega 网盘也是一样的情况,它将所有的数据都放在浏览器内,直到下载完成时才会交给用户肯定也会遇到一样的问题。毕竟除了为了断点续传外,按照 Mega 网盘的说法,传输的数据是加密过的,所以需要在浏览器内解密并拼合后才能拿到实际的文件数据。从 Mega 的 源码 里可以看出它做了非常多的优化来绕过浏览器的限制:
var dl_method; // 0: Filesystem API (Chrome / Firefox Extension polyfill // 1: Adobe Flash SWF Filewriter (fallback for old browsers) // 2: BlobBuilder (IE10/IE11) // 3: Deprecated MEGA Firefox Extension // 4: Arraybuffer/Blob Memory Based // 5: MediaSource (experimental streaming solution) // 6: IndexedDB blob based (Firefox 20+)
现在有了 Streams API,我们就有了另一种解决方式。StreamSaver.js 就是这样的一个例子,它借助了 Streams API 和 Service Worker 解决了内存占用过大的问题。阅读它的源码,可以看出它的工作流程类似下面这样:
StreamSaver.js 包含两部分代码,一部分是客户端代码,一部分是 Service Worker 的代码(对于不支持 Service Worker 的情况,作者在 GitHub Pages 上提供了一个运行 Service Worker 的页面供跨域使用)。在初始化时客户端代码会创建一个 TransformStream
并将可写入的一端封装为 writer
暴露给外部使用,在脚本调用 writer.write(chunk)
写入文件片段时,客户端会和 Service Worker 之间建立一个 MessageChannel
,并将之前的 TransformStream
中可读取的一端通过 port1.postMessage()
传递给 Service Worker。Service Worker 里监听到通道的 onmessage
事件时会生成一个随机的 URL,并将 URL 和可读取的流存入一个 Map 中,然后将这个 URL 通过 port2.postMessage()
传递给客户端代码。客户端接收到 URL 后会控制浏览器跳转到这个链接,此时 Service Worker 的 onfetch
事件接收到这个请求,将 URL 和之前的 Map 存储的 URL 比对,将对应的流取出来,再加上一些让浏览器认为可以下载的响应头(例如 Content-Disposition
)封装成 Response
对象,最后通过 event.respondWith()
返回。这样在当客户端将数据写入 writer
时,经过 Service Worker 的流转,数据可以立刻下载到用户的设备上。这样就不需要分配巨大的内存来存放 Blob,数据块经过流的流转后直接被回收了,降低了内存的占用。
事实上,现在的 Firefox Send 对支持的浏览器就使用了类似上述的流程实现,当用户下载文件时会发出请求,Service Worker 接收到下载请求后会建立真实的 fetch 请求连接服务器,将返回的数据实时解密后直接下载到用户的设备上。这样的直观效果是,浏览器直接下载了文件,文件会显示在浏览器的下载列表中,同时页面上还会有下载进度:
所以借助 StreamSaver.js,之前下载图片的流程可以优化如下:JSZip 提供了一个 StreamHelper
的接口来模拟流的实现,所以我们可以调用 generateInternalStream()
方法以小文件块的形式接收数据,每次接收到数据时数据会写入 StreamSaver.js 的 writer,经过 Service Worker 后数据直接被下载。这样就不会再像之前那样在生成 zip 时占用大量的内存空间了,因为 zip 数据在实时生成时被划分成了小块并迅速被处理掉了。
课后习题 Q3:StreamSaver.js 在不支持
TransformStream
的浏览器下其实是可以正常工作的,这是怎么实现的呢?
经过了这么长时间的学习,我们从 Fetch API 的角度出发探索 Streams API,大致了解了以下几点:
pipeTo()
、pipeThrough()
方法方便地将多个流连接起来ReadableStream
是可读取的流,WritableStream
是可写入的流,TransformStream
是既可写入又可读取的流Response
对象,它的 body
属性是一个 ReadableStream
onfetch
事件可以监听所有的请求,并对请求进行篡改download
属性下载文件,Blob
对象,MessageChannel
双向通信……Streams API 提出已经有很长一段时间了,由于浏览器支持的原因再加上使用场景比较狭窄的原因一直没有得到广泛使用,国内的相关资料也比较少。随着浏览器支持逐渐铺开,浏览器原生提供的可读取流和可写入流也会逐渐增加(比如在本文即将写成时才注意到 Blob
对象已经支持 stream()
方法了),能使用上的场景也会越来越多,让我们拭目以待吧。
tee()
方法得到了两个流,但我们只读取了其中一个流,另一个流在之后读取,会发生什么吗?
使用 tee()
方法分流出来的两个流之间是相互独立的,所以被读取的流会实时读取到传递的数据,过一段时间读取另一个流,拿到的数据也是完全一样的。不过由于另一个流没有被读取,克隆的数据可能会被浏览器放在一个缓冲区里,即便后续被读取可能也无法被浏览器即时 GC。
const file = document.querySelector('input[type="file"]').files[0]; const stream = file.stream(); const readStream = (stream) => { let total = 0; const reader = stream.getReader(); const read = () => reader.read().then(({ value, done }) => { if (done) return; total += value.length; console.log(total); read(); }); read(); }; const [s1, s2] = stream.tee(); readStream(s1); readStream(s2);
例如在上述代码中选择一个 200MB 的文件,然后直接调用 readStream(stream)
,在 Chrome 浏览器下没有较大的内存起伏;如果调用 stream.tee()
后得到两个流 s1
和 s2
,如果同时对两个流调用 readStream()
方法,在 Chrome 浏览器下同样没有较大的内存起伏,最终输出的文件大小也是一致的;如果只对 s1
调用的话,会发现执行结束后 Chrome 浏览器下内存占用多了约 200MB,此时再对 s2
调用,最终得到的文件大小虽然一致,但是内存并没有及时被 GC 回收,此时浏览器的内存占用还是之前的 200MB。
可能你会好奇,之前我们尝试过使用 tee()
方法得到两段流,一个流直接返回另一个流用于输出下载进度,会有这样的资源占用问题吗?会不会出现两个流速度不一致的情况?其实计算下载进度的代码并不会非常耗时,数据计算完成后也不会再有多余的引用,浏览器可以迅速 GC。此外计算的速度是大于网络传输本身的速度的,所以并不会造成瓶颈,可以认为两个流最终的速度是基本一样的。
Q2. **如果不调用 controller.error()
抛出错误强制中断流,而是继续之前的流程调用 controller.close()
关闭流,会发生什么事吗?
从上面的结果来看,当我们调用 aborter()
方法时,请求被成功中止了。不过如果不调用 controller.error()
这个方法抛出错误的话,由于我们主动关闭了 fetch 请求返回的流,循环调用的 reader.read()
方法会接收到 done = true
,然后会调用 controller.close()
。这就意味着这个流是被正常关闭的,此时 Promise 链的后续操作不会被中断,而是会收到已经传输的不完整数据。
如果没有做特殊的逻辑处理的话,直接返回不完整的数据可能会导致错误。不过如果能好好利用上的话,或许可以做更多事情——比如断点续传的另一种实现,这就有点像 Firefox 的私有实现 moz-chunked-arraybuffer
了。
Q3. **StreamSaver.js 在不支持 TransformStream
的浏览器下其实是可以正常工作的,这是怎么实现的呢?
记得我们之前提到过构造一个 ReadableSteam
然后包装成 Response
对象返回的实现吧?我们最终的目的是需要构造一个流并返回给浏览器,这样传入的数据可以立即被下载,并且没有多余引用而迅速 GC。所以对于不支持 TransformStream
甚至 WritableStream
的浏览器,StreamSaver.js 封装了一个模拟 WritableStream
实现的 polyfill。当 polyfill 得到数据时,会将得到的数据片段通过 MessageChannel
直接传递给 Service Worker。Service Worker 发现这不是一个流,会构造出一个 ReadableStream
实例,并将数据通过 controller.enqueue()
方法传递进流。后续的流程估计你已经猜到了,和当前的后续流程是一样的,同样是生成一个随机 URL 并跳转,然后返回封装了这个流的 Response
对象。
本文基于 CC BY-SA 4.0 协议 进行许可,使用本文请注明来源。
要说和今天相关的事情的话,其实,除了 xn--eekf.xn--q9jyb4c 这个域名以外,在半年前还买了个 loli.uno。当然,买了并不代表用上了,所以暂时就重定向到 xn--eekf.xn--q9jyb4c 上了。结果大概上个月的时候 IP 被墙了,所以其实你们现在直接连接也访问不了了。
traceroute 断在了 Multacom 机房的路由,ssh 后 traceroute 到国内的 IP 断在了骨干网,ping 其他邻居的 IP 基本没有事。所以应该不是屏蔽网段,而是直接针对 IP 屏蔽了。不过也奇怪应该没有什么大流量之类的,发现出问题的时候还在公司走 ss 用 Telegram,并没有什么大流量,不是很懂。嗯……难不成之前是瞎玩写的 xn--eckxj725iv5g.xn--eekf.xn--q9jyb4c 被发现了,然后就被屏蔽了?反正不是很懂,目前暂时靠 CloudFlare 续命,十一回家的时候发现家里的联通网络相性非常好,还能流畅在 YouTube 看阅兵(?
另外上个月折腾了下 Oracle Cloud,选择了日本线路,延迟感人,绕道到 ping ~200ms,speedtest 到东京的测试服务器上行 48Mbps 下行 4Mbps,iperf3 也就 10Mbps。由于羊毛被褥的太狠,想开第二台机器做内网 iperf3 测试,结果开通的时候提示已经售罄了,所以至今不知道这玩意的网络配置是什么样,感觉或许还不如美西节点,美国节点还有免费邮件额度。相比之下韩国节点可是在公司跑到了 500Mbps 的,不过韩国基本不能访问 porn 也没什么用,日本节点再慢,至少还能看个 DMM(虽然也不知道能买什么,也不玩页游,所以……好像也没用)。
或许之后会试着在那个东京节点上做一份 mirror,有时间再折腾吧。对速度感兴趣的同学可以访问这个对象存储的链接,看看速度有多渣:https://objectstorage.ap-tokyo-1.oraclecloud.com/p/VdT8VdFqkU-e4Tex4GnHK-VA6XwCnH_7zjpnM_Xtnr4/n/nrzblovvku9x/b/bucket-ccloli/o/76825905_p0.jpg 。总之,这个东京节点路由不行,虽然 traceroute 机器的上一个路由是从广州出口直接到 Tata 的,不过还是很慢,对东亚用户来说还不如美国线路(韩国用户和日本用户除外?),反正现在的 ping 值比到位于西海岸 LA 的 Multacom 机房的 200ms 还高,夜间高峰期线路干扰更可怕,丢包率 50%……除了能访问只有日本 IP 能打开的网站外,还不如美西节点。
嗯,总之,大概就这样吧,明年的存活报告或者明天见。
]]>嗯,先赶在 23:59 前先发布了,然后再来编辑(
感觉每年十月十日发文章就当是存活报告好了。
嗯,就这样,明年见。
]]>