我如何用 Windows 开发 —— 2021 我的开发环境

由于搭载 macOS 的设备越来越昂贵且槽点颇多,加上微软近些年开始发力开发者体验,我的工作环境已经从 MacBook 切换到了运行 Windows 系统的非苹果机器。这是多方面因素决定的:平时玩的游戏需要在 Windows 上运行,桌面环境也是 Windows 的比较舒适耐用。

使用 Windows 进行开发工作虽说并不像在 macOS 上那样可以使用诸多 POSIX 标准的工具,但鉴于硬件性能的提升和虚拟化技术的成熟,使用虚拟机或者 WSL2 也未必是不可接受的方案。此外,随着 Visual Studio Code 的 Remote 功能越发完善,在 Windows 的窗口环境下享受和 Linux 一样的开发体验也并非不可能。

当然,使用 Windows 开发完全是个人选择;本文并不打算讨论 Windows 对比其它操作系统的优劣,只是在此简单介绍我如何在 Windows 桌面上进行我的开发工作,希望对因为种种原因选择使用 Windows 工作的读者能有所启发。

需求

在讨论开发环境之前,我们首先得讨论开发的需求是什么。我个人写的东西还挺杂的,大部分时候使用 Visual Studio Code 写 TypeScript, JavaScript 和 PHP。除此之外,我有时候会使用 Android Studio 写 Java/Kotlin,或者用 Goland 写 Go,又或者使用 Visual Studio 2019 写 C#。此外,我还需要运行 DataGrip 之类的数据库管理软件、kubectl 之类的集群管理软件,还需要使用 ssh 登录数台远程主机。我的个人项目几乎都使用 docker 进行部署,因此 docker 也必不可少。

总体来说,我需要:

  • Visual Studio Code
  • Visual Studio
  • JetBrains IDE,包括:
    • Android Studio
    • Goland
    • DataGrip
  • 终端、zsh
  • docker

了解了自己的需求之后,就能根据自己的需求来考察选项。

OS

如标题所述,我的宿主机基本上是 Windows 操作系统,一般是最新的 Release 或者 Beta 版本,比如在写作文章的时候是 Windows 11 (Pro) Insider Beta。这目前是为了使用 wslg,在 Windows 11 正式发布之后我多半会切换到稳定版上。

鉴于平日打交道的服务器多半是 Debian 或者 Ubuntu 这类 Linux 发行版,本地开发环境上有一个 Linux 的机器很重要,我称之为 Linux dev box。在这个 Linux dev box 内的操作系统上,我选择使用 ArchWSL 提供的 ArchLinux 镜像作为日常的 dev box 使用,这主要是考虑到 ArchLinux 的软件包比较新,而且 AUR 上有很多方便的包可以选择使用。在非生产环境下,使用 ArchLinux 的体验还是不错的。

虚拟化

Linux dev box – WSL2

在 Windows 宿主机上运行 Linux box 是很简单的事情,而且你有诸多选择:以 VirtualBox 为代表的虚拟机方案;WSL1;WSL2。我的选项是 WSL2,你可能觉得 WSL2 和虚拟机也没什么区别,事实上几乎如此,只是省去了不少自己对虚拟机做的集成配置。WSL1 曾经是我梦想中的方案,但 WSL1 作为日常使用的子系统还是有其种种问题,其环境和物理或者 KVM 虚拟化的 Linux 还是有所区别,IO 也令人发指。

一个比较常见的问题是 systemd,众所周知 WSL 不管是 1 还是 2 都不支持直接启用 systemd,大概是因为微软自己启动的缘故。虽然没有官方的支持,但使用 genie 提供的 bottled 方案也并不是一个很难接受的选择,事实上体验基本与 systemd 一致——当然如果你需要的某些复杂高级功能工作不正常,那当我没说。听说 wsl2-hacks 也是个办法,但我没有尝试过。

另外,由于微软的奇葩设计,在每次 Windows 启动的时候 WSL2 都会被随机分配到完全随机的网段,这会导致不少问题。我遇到的主要是 Windows 下的防火墙难以配置WSL2 内连宿主机 IP 不稳定;还有选到和 docker 等虚拟环境冲突的网段导致路由错乱。前两个问题我通过一些脚本来解决,我把他们放到了 GitHub 的 oott123/work-on-windows 上供读者参考;而最后一个问题我也通过了一些办法解决,后文提到 docker 的时候再仔细说。

用上 WSL2 之后,wslg 也变得很简单,只需要 export DISPLAY=:0 并且 export WAYLAND_DISPLAY=wayland-0 就可以让 Linux 桌面程序显示成 Windows 窗口了。不过在我这边,wslg 的剪贴板不一定工作,我找到了一个 issue,但看起来没什么帮助的样子。之后也许要尝试自己去做剪贴板同步了。

如果你不想使用 Insider 系统来启用 wslg,也可以试试第三方方案 X410 。X410 ( X for 10 ) 是一款商业化的 X Server,相比 VcXserv 有更好的 HiDPI 支持,同时体验也更加流畅。官网有一些非常详尽的教程,例如和 WSL2 配合使用的和 Hyper-V 配合使用的,都可以看看。

Windows dev box – Windows 10

基于某种“不想在宿主机上安装开发环境”的奇怪洁癖矫情,我没有在我的宿主机器上安装 Visual Studio 、Android Studio 等开发环境,而是选择使用 VMWare 创建了一个虚拟机,并在虚拟机内安装。这台虚拟机里安装了 Visual Studio、Windows SDK、Android Studio 和 Visual Studio Code 等等 IDE 或编辑器。

因为使用频率比较低,所以也没咋配置,直接用 VMWare 开干了。实际体验只能说尚可接受,操作起来还是偶有迟滞的感觉,反正用得不多,懒得管了。

开发环境

IDE / SDK

鉴于我有两个 dev box ,那么某个 IDE 或者环境该在哪个 box 里安装无疑成了需要选择的事情。目前,我的基本准则是,能在 WSL2 里安装的,都在 WSL2 里安装:

  • Visual Studio – 宇宙第一 IDE 只支持 Windows,所以在 Windows box 里安装
  • Android Studio – WSL2 连实体机不方便,所以在 Windows box 里安装
  • Goland / DataGrip – WSL2 里安装,wslg 使用
  • Sublime Merge – WSL2 里安装,wslg 使用
  • nodejs, yarn, php 等命令行开发环境 – WSL2 里安装

Visual Studio Code

Visual Studio Code 的 Remote 功能是本世纪以来最伟大的发明。
—— 三三・自己说的

早在 coder 发布 code-server 的时候,我就搭建了一套用于日常开发;后来 vscode 发布了官方支持的 Remote 功能之后,我也就随之迁移到了 Remote 上进行开发。之前还需要通勤的时候,无论在公司还是在家里,都可以用 vscode remote 连接到自己的服务器摸一些东西;现在的话主要用于在 Windows 上连接 WSL 开发。

显然,我的 Visual Studio Code 是安装在宿主机上的;但使用体验和安装在 Linux 上并没有什么分别,一切都很顺滑。

Windows Terminal

我平时使用 Windows Terminal 作为我的 shell,大概配置成这个样子:

这张图片展示了使用 Windows Terminal 连接 WSL2 运行 neofetch 的结果。

在 Windows Terminal 中,为 WSL2 的 Profile 设置开始路径为 \\wsl$\Arch\home\oott123 这样的目录,就可以实现打开新标签的时候切换到 WSL 的家目录了,在这里就可以方便地存放自己的代码和开发环境了。注意不要把代码放到 /mnt/c/ 之类的地方去,否则你的 IO 会很惨。我平时使用 zsh 作为默认 shell,在 WSL2 里也是能直接使用的,就和普通的 Linux 系统没什么区别了。

另外,我使用了修改过的 zsh-notify 插件,配合 BurntToast实现命令错误/长时间任务完成推送 Windows 通知的功能。虽然由于偷懒,没有去检测当前焦点窗口是否在 shell 中导致命令报错的时候稍微有点吵之外,其实还挺好用的。

Docker

在 WSL2 里安装 docker 也是一件有两个选择的事情:使用 Docker Desktop,或者直接安装 docker daemon。我曾经选择了前者,还用上了非常酷炫和魔法的 WSL2 daemon,直到它出现了不少问题,最后由于我的 Arch 内核太新出现了一个我修不好的问题,一气之下就把它整个删掉了,安装了正常的 docker daemon。

前文提到过,WSL2 启动的时候会随机选择网段,有时候会选到和 docker 等虚拟环境冲突的网段导致路由错乱。后来我在 Windows 内创建一个和 docker 同网段的虚拟网卡之后,Hyper-V 似乎就很聪明地避开了这个网段,问题得以解决。

打开设备管理器,选择操作-添加过时硬件,依次选择安装手动从列表选择的硬件、网络适配器、Microsoft、Microsoft KM-TEST,并确认即可
使用设备管理器添加 KM-TEST 虚拟网卡

体验

在 Microsoft 大力拥抱开源社区的今天,使用 Windows 作为开发环境已经不是令人难以接受的事情了——当然前提是你使用 WSL2,我不会真的在 Windows 这样的 OS 上做开发的。微软的 Windows 虽然从来都广受诟病,但它的桌面环境体验不是其它竞争者(是的,我是说 Linux。你说什么 m 什么 OS 是啥,我听不见)可以比拟的。如果说曾经基于 BSD 内核的 macOS 拥有类似 UNIX 的开发体验,那么 WSL2 则是 99.9% 的 Linux 开发体验,这对于我来说是非常重要且舒适的。是的,绝对不是因为我要用 Windows 打游戏。

因为基于虚拟机的 dev box 使得备份和迁移变得异常方便,全虚拟化环境的拟真也能带来许多优势,而 WSL2 和 Windows 的集成也足够顺滑,所以我想,在可以预见的将来,我的开发环境仍然会首选宿主机 Windows + Linux / Windows dev box 的模式来配置。

锱铢必较的 gzip 设置方法

在设置 web 服务器的时候,很多人会把 gzip 或是 brotil 打开,以期获得更快的传输速度——毕竟文件压缩了,那传输就变快了。

事实真的如此吗?

众所周知,gzip 或是 brotil 这样的压缩算法是需要消耗 CPU 的。如果文件没有经过预先处理,那多半是在访问时进行压缩的。在小流量的站点上这其实并非问题,但对于某些大流量站点这可能是问题。

网上有很多教程教你如何静态压缩文件,并使用 gzip_static 模块来提供服务——这很好,因此在这里我们不谈论这个,感兴趣的读者可以自己找来学习。

这里要来讨论的是 gzip 的另一个盲区问题——经过 gzip 压缩之后,文件真的变小了吗?

我们来观察一下 Google 首页加载的 logo 图片

浏览器请求 Google 首页 logo 的 DevTools 截图。

从 Google 服务器的响应上来看,可以很清楚地看到,服务器并没有使用 gzip 或是 brotil 对这张图片进行压缩。

我们将这张图片下载回来,然后在本地使用 gzip 命令对其进行压缩:

wget https://www.google.com/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png
gzip -k googlelogo_color_272x92dp.png
ls -l googlelogo_color_272x92dp.png*

输出结果如下:

-rw-r--r-- 1 oott123 oott123 5969 Oct 23  2019 googlelogo_color_272x92dp.png
-rw-r--r-- 1 oott123 oott123 6022 Oct 23  2019 googlelogo_color_272x92dp.png.gz

可以看到,gzip 之后的文件大小为 6022 字节,甚至比原始的 5969 字节还要更大一些。

聪明的你肯定知道为什么:PNG 格式本身就使用和 gzip 如出一辙的算法 DEFLATE 压缩过了,再用 gzip 压缩一遍反而是徒增烦恼,即使文件没有变大,也不会变小太多。

事实上许多网络上传输的文件都是有内部有压缩实现的,例如 png, jpg, zip, rar, 7z, docx 。在 web 服务器上对这些文件进行压缩,起不到什么压缩的效果,反而浪费了压缩解压的时间和功。因此,合理地配置需要压缩的文件类型才是正确的选择,一股脑全部打开只是浪费——当然了,也浪费不了多少,几乎不会产生什么显著影响。

当然,总会有些人觉得压缩两次不舒服,比如我。

这次我使用的是 traefik 作为我的流量入口,因此找到了它的 gzip middleware 配置

可以看到,traefik 支持使用 excludedContentTypes 配置 gzip 排除名单。懒人如我,自然开始上网搜寻现成的——然而没找到,只找到一份需要用 gzip 压缩的 mime 名单。事实上我也觉得在 gzip 这件事情上用白名单比较好,因为需要压缩(且压缩率大)的那些文件其实很明显:html, css, javascript,有时候还有 json。可是 traefik 没有提供设置白名单的方法,所以只能自己动手整理一份黑名单了。

于是我打开 nginx 内置的 mime 列表 ,挑出来一些常见的、我知道带压缩的格式,放进了排除名单里。

这份名单提供在这里,供有需要的人参考:

image/jpeg
image/png
image/webp
audio/webm
audio/mpeg
video/webm
video/mp4
video/mpeg
video/mp2t
application/zip
application/x-zip-compressed
application/x-rar-compressed
application/x-7z-compressed
application/java-archive
application/vnd.openxmlformats-officedocument.presentationml.presentation
application/vnd.openxmlformats-officedocument.wordprocessingml.document
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
application/x-bzip
application/x-bzip2
font/woff
font/woff2

其中还藏着一个冷知识:TTF 几乎没有压缩,而 woff / woff2 分别采用了 zlib 和 brotil 进行压缩,因而体积更小,更适合在 web 上传播。

总而言之,如果你的网站流量并不是很大,而且也不怎么在意这点微不可察的加载时间差距,那么我建议你还是别折腾,直接 gzip on 就完事了。如果你真的是锱铢必较,或者有所谓的强迫症,那这篇文章还是有参考价值的。

一块硬盘的救赎——尝试从无法读取的机械硬盘里拯救数据

一直在我桌面电脑上工作,服役超过4年的一块西数硬盘昨天重启电脑之后突然无法被 Windows 识别了。因为我系统里大量用户数据在这个盘,结果导致我整个 explorer.exe 乃至任务管理器都无法启动,只好打开机箱把它拔掉,才得以进入 Windows。拜此事故所赐,我才意识到原来我有那么多日常使用东西是放在这块机械盘上的——此前我一直以为除了一些下载内容和一些过程产物巨大的 C++ 项目之外别无他物。然而硬盘已经坏掉,此时想这么多已经于事无补,不如想想办法把数据能救多少是多少。

⚠警告:本文包含大量个人经验和未经大规模验证的操作记录,因参考本文操作导致的任何数据损坏或丢失,作者概不负责。数据无价,请三思而后行!

首先来说一下这块盘的基本信息。这是一块 WDC WD10EZEX-08WN4A0 ,商品名就是西数蓝盘,接口是 SATA 3.1 的,尺寸是 3.5 寸。用 SMART 去看硬盘信息,通电已经 1.1 万小时了,而且不知道什么时候 C5 已经警告了,挂掉也不是什么太稀罕的事情。因为挂到我 Windows 上面,Windows 会尝试去读取它(然后失败)而导致 explorer.exe 开不起来,因此我把它拔了下来,挂到了另一台 homelab 上打算尝试导出数据。然而事与愿违,这块盘挂到我 homelab 机器上之后,连着机器上原有的盘一起失踪了,BIOS 都看不到他们俩。可能是我这主板的 SATA 口有什么毛病,总之屋漏偏逢连夜雨,我暂时性地失去了访问这块硬盘的方法。

DiskGenius 的 SMART 截图,上面显示 C5 项已经是警告状态了
用 DiskGenius 查看的这块盘的 SMART 信息

于是我在京东下单了一个 USB-SATA 数据线——说是数据线其实也不太准确,因为这玩意里面有一个 SATA 控制器——是优越者(UNITEK)的 Y-1093BBK,从 lspci 信息上看是 ASMedia 的 ASM1062 的控制器。因为我这块盘是 3.5 寸的,于是选择了有电源适配器的型号,用于给硬盘做供电。到货之后,简单地将硬盘连接到数据线上,再插入外接供电,然后把 USB 插入到 NAS 的 USB3.0 接口上。幸运地,NAS 上的 Ubuntu 正确地识别了这块硬盘而没有发生任何预料外的问题——不是和 homelab 一样直接和另一块盘一起捉迷藏,或者和桌面电脑一样让系统挂掉——总之正确地识别到了这块盘,这也让恢复数据有的放矢起来。

首先运行一个 lsblk 看看块设备:

$ lsblk -o name,label,size,fstype,model
NAME    LABEL            SIZE FSTYPE            MODEL
[... 此处省略无关硬盘 ...]
sdf                    931.5G                   EZEX-08WN4A0
├─sdf1                   128M
└─sdf2  Storage        931.4G ntfs

从 lsblk 的信息来看,这块盘的设备位于 sdf。既然识别到了,那么首先要做的事情就是把这块盘做个镜像,方便之后的数据恢复。说到镜像,我首先想到的是 dd,但这块硬盘已经有一些物理故障了, dd 未必能很好地完成任务。咨询了有很多块硬盘的 @orzfly 后,他告诉我可以使用 ddrescue 来为有问题的硬盘做镜像。

ddresuce —— 我是说,GNU ddrescue,是一个数据恢复工具。它能用各种方法跳过硬盘里的坏块,将尽量好的部分读取出来,并以此创建一个部分的镜像,从而避免进一步的数据损失。在 Ubuntu 上,gddresuce 是提供它的包,因此我首先使用 apt 以安装它:

apt install gddrescue

然后就是尝试读取这块硬盘了。首先,普通地运行一个镜像命令(对了,不要忘记在 screen 或者 tmux 里执行,否则……):

ddrescue /dev/sdf disk_recover_from_crash.img disk_recover_from_crash.log

ddrescue 的第一个参数就是我们要恢复的硬盘。安全起见,我没有使用某个分区,而是尝试将整个硬盘作为镜像源;第二个参数是输出路径,我这里输出到一个镜像文件;第三个参数是日志文件,这个日志文件会记录扇区的好坏信息,用于进一步的操作,这十分重要。

运行之后就能看到一些实时更新的统计信息,大概这样:

     ipos:  640862 MB, non-trimmed:    65536 B,  current rate:  34799 kB/s
     ipos:  640827 MB, non-trimmed:    65536 B,  current rate:  35520 kB/s
     opos:  640827 MB, non-scraped:        0 B,  average rate:  33094 kB/s
non-tried:  640293 MB,  bad-sector:        0 B,    error rate:       0 B/s
  rescued:  359911 MB,   bad areas:        0,        run time:      3h 59s
pct rescued:   35.98%, read errors:        0,  remaining time:      5h  4m
                              time since last successful read:          0s
Copying non-tried blocks... Pass 1 (backwards)

这些数据的含义在 ddrescue 的 manual 里都能查到,这里就不多赘述。我关注的主要是 “rescued” 和 “bad-sector” 这两个数据;前者决定了有多少数据已经被读取出来了,而后者是说有多少数据无法读取。如果幸运的话,等它运行完就能得到一个完整的磁盘镜像了——可惜我并非幸运的。运行 5 秒后,我的 ddrescue 直接卡住了,哪怕是 run time 也不再更新。等待了半个小时无果后,我冒着数据再次丢失的风险把 USB 拔了出来。拔出来的操作可谓是立竿见影,ddrescue 抱怨了一句 Input device is missing 之后就退出了。

当然,恢复工作还得继续。抱着玄学的想法,我把 USB 数据线换了一个接口,并且把命令改成了从后往前读。因为机械硬盘前面部分写入读取较多,前面也更容易坏,所以我觉得也许从后往前读会更稳一些。反过来的命令是这样:

ddrescue -R /dev/sdf disk_recover_from_crash.img disk_recover_from_crash.log

只是加了一个 -R 的参数而已。幸运的是,这次终于运行良好。我也不知道是移动 USB 接口起了作用还是倒序读取起了作用,总之它在正常地运行了。根据程序输出的提示,我大概需要等待8小时左右。

…a few moments later…

跑完之后,来看结果:

     ipos:  533937 kB, non-trimmed:        0 B,  current rate:       0 B/s
     opos:  533937 kB, non-scraped:        0 B,  average rate:  34502 kB/s
non-tried:        0 B,  bad-sector:     8192 B,    error rate:     128 B/s
  rescued:    1000 GB,   bad areas:        2,        run time:  8h  2m 54s
pct rescued:   99.99%, read errors:       17,  remaining time:         n/a
                              time since last successful read:         44s
Finished

我还挺幸运的,只有 8192B 的地方读不出来。换言之这盘只坏了两个扇区,就让 Windows 读不出来了……真是玄学。出于不死心,我又尝试了强行去读取这两个损坏的扇区:

ddrescue -r3 /dev/sdf disk_recover_from_crash.img disk_recover_from_crash.log

参数 -r 可以指定对坏块的重试次数,这里尝试三次。后面的两个参数保持不变,这样 ddrescue 才能读取 log 里保存的进度,以跳过已经恢复好的部分。不过这两个块可能真的坏了,我这里尝试了之后也没有任何改善。看日志:

$ cat disk_recover_from_crash.log
# Mapfile. Created by GNU ddrescue version 1.22
# Command line: ddrescue -r3 /dev/sdf disk_recover_from_crash.img disk_recover_from_crash.log
# Start time:   2021-01-09 22:40:26
# Current time: 2021-01-09 22:43:29
# Finished
# current_pos  current_status  current_pass
0x221D4E00     +               3
#      pos        size  status
0x00000000  0x1FD33000  +
0x1FD33000  0x00001000  -
0x1FD34000  0x024A0000  +
0x221D4000  0x00001000  -
0x221D5000  0xE8BEBE1000  +

可以看到两个坏掉的扇区偏移量和坏掉的尺寸。因为两个都正好是 4k,我又用 fdisk 检查了一下 sector size:

$ fdisk -l /dev/sdf
Disk /dev/sdf: 931.5 GiB, 1000204886016 bytes, 1953525168 sectors
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 4096 bytes
I/O size (minimum/optimal): 4096 bytes / 4096 bytes
Disklabel type: gpt

结果 fdisk 说这个盘的 sector size 是 512 bytes。但 ddrescue 读取的单位可能是 4k,因为有内核缓存什么的。既然知道了 sector size,还可以尝试一下 direct disc access 来看看能不能多救回来几k的数据:

ddrescue -r3 -d --sector-size=512 /dev/sdf disk_recover_from_crash.img disk_recover_from_crash.log

加了 -d 代表 direct disc access,--sector-size 则是用 fdisk 读出来的 sector size。继续执行,让 ddrescue 再多读了几次。我这里反正没有成功,这 4k 的数据大概是永远都救不回来了。

救回来之后的 disk image 可以用 Mount Partitions in Disk Image 文中介绍的方法挂载:

losetup -P /dev/loop0 disk_recover_from_crash.img
mkdir /tmp/recover
mount -t ntfs -o ro /dev/loop0p2 /tmp/recover
cd /tmp/recover

简单看了下,表面上数据还是挺正常的,至少这 4K 的数据丢了造成的影响并不是特别明显。最后参考 Disconnect/Eject SATA devices in Linux 一文卸载硬盘:

echo 1 > /sys/block/sdf/device/delete

之后拔掉 USB 和电源。至此,这块硬盘上的数据已经拯救了 99.99% 回来。因为出现过坏块,所以这块硬盘也不太可能继续服役了,屏蔽坏块让他恢复正常什么的就下次再说吧。

多人游戏的网络实现:帧同步和状态同步

最近在想开发一个多人的 3D 游戏。在多人游戏中,网络同步是一个重要且不可忽视的模块。由于我之前没有做过真正意义的游戏开发,在搞懂游戏的网络编程的路上走了一些奇奇怪怪的弯路,谨以此文记录我的理解与总结,辅助自己消化,也请各位读者斧正。

在多人游戏里,网络同步要实现的目的主要有:

1. 看见:看见视野内其他玩家的位置、面向、动作等
2. 互动:视野内其他玩家进行互动,如攻击、辅助、治疗等
3. 实时:在网络延迟客观存在的前提下,保证各玩家看到的状态尽量一致,对玩家的操作做出尽量快的反应

基于以上目的,我们来讨论多人游戏的网络实现。目前而言,多人游戏的网络同步,主流实现可以分为两大类:帧同步和状态同步。

帧同步,一般而言是 P2P 架构。它的核心思路是:对同样的输入,每个客户端做出同样的操作,即可模拟视野内其他玩家的动作。这样的同步架构,一般而言只需要把少量的玩家输入分发给其他客户端,由其他客户端在本地进行模拟即可。例如一个 RTS 游戏,只需要把玩家的选兵、行动指令分发给其他客户端,由他们进行模拟即可。

状态同步则多采用 C2S 架构。它的核心思路则是:客户端将自己的操作或者局部状态交给服务器,由服务器来运算出游戏内的全局状态,最后由服务器把游戏内的状态分发给其他客户端(可能也包括自己),而客户端只是负责根据这些状态渲染出对应的画面而已。例如一个 MMORPG 游戏,每个玩家将自己所在的位置传递给服务端,服务端收集到各个玩家的位置之后,再分发给其他客户端。

两种网络同步算法各有千秋。总的来说,帧同步算法更适合于做 RTS / FPS / MOBA 类游戏:这类游戏输入简单、对实时性要求非常高,且有“局”和“房间”的概念,一局游戏所能持续的时间有限;而状态同步算法则更适合于 MMORPG 类游戏:这类游戏动作繁多,但对实时性要求可以容忍(甚至可以加入前摇、公 CD 等机制强行增加延迟),且游戏内部状态数据非常庞大、时间轴也会延续很长。

帧同步算法

帧同步算法的实现也有很多不同的样子,这里介绍一种较为简单的方法,即“锁定帧”同步(Lock-step)。

锁定帧同步的核心在于“同步帧”。你需要在游戏中将运算帧分为同步帧和非同步帧。在非同步帧时,你收集本地玩家的所有输入,并将其发送给网络上的其他玩家;同时保存网络上其他玩家发送过来的数据包。到了同步帧,你将网络上的所有操作拿出来,并且在游戏中演算他们。为了保证游戏的实时性和公平性,在同步帧时你可能需要将所有其他玩家的操作数据都收集到,才能进行接下来的游戏过程;但这样可能会导致一个玩家卡顿、所有玩家都无法进行游戏。

关于锁定帧更详细的实现方法,可以参考 Clinton Brennan 的 Lockstep Implementation in Unity3D 一文,也可以参考 bindog 的 游戏中的网络同步机制——Lockstep 一文。

当然,帧同步也有很多不需要锁定帧的方法,这里就不再赘述了。由于传统的帧同步很难解决“开图”之类的反作弊外挂(因为所有玩家的动作都在你本地存着,只是被挡住了而已),所以也有很多和状态同步结合在一起使用的例子。

状态同步算法

状态同步的思路和帧同步相比,客户端的实现要简单很多。你只需要把所有的操作都告诉服务器,让服务器帮你运算,最后将状态同步回本地即可。

说起来非常简单,但事实上很少有游戏是把所有的运算都放到服务器上的。究其根本原因,在于人类的科技实在是太落后了,还无法将网络通信做得比光速更快……扯远了,根本原因在于有网络延迟。如果所有游戏都这么实现,那恐怕是一场灾难:用户往前一步,要等一会儿才会看到自己的角色移动了……

现在很多的状态同步游戏,会在本地做一些运算,并把运算的结果告知服务器。比如移动,可能会定期把当前的坐标信息发给服务器。而服务器收到之后呢,就把这些坐标再告诉其他客户端,其他客户端收到后,再对应的坐标渲染一个人物,这就完事了。

可以看出来,在有网络延迟的情况下,客户端收到的其他玩家的坐标信息必然是有延迟的。在忽略服务器处理的延迟时间的情况下,其延迟时间等于两个玩家到服务器的延迟的总和。在当今的网络条件下,100ms 的延迟都是很常见的,因此玩家看到的其他玩家可能延迟 200ms。如何处理这 200ms ,也许各个游戏都应该有自己的策略:

预测-修正算法

这种方法在云风的BLOG 开发笔记(12) : 位置同步策略 中有提到,在 Gabriel Gambetta 的 Fast-Paced Multiplayer (Part III): Entity Interpolation 一文中也有类似的算法。最初,我也采用这种方法来实现我的游戏。这种方法追求实时性,即,希望玩家看到其他玩家“现在”的位置。

例如,现在的时间 t = 1000;当你收到一个来自 200ms 以前(t=800)、其他玩家的位置数据,你应当根据他的速度,来计算出 400ms 以后(t=1200)它在哪;然后,通过补间动画,将这个玩家平滑的移动到它的位置。这样,如果这个玩家一直在往前移动,当 t=1200 时,你看到的它的位置将是准确无误的。

当然这种方法有很明显的缺点:如果这个玩家在 t=1000 的时候突然掉头往反方向行走,那么你在 t=1200 计算出来的位置是明显错误的;此时,你可能看到这名玩家突然加速向反方向移动。此外,如果玩家在移动过程中进行跳跃,也是很难模拟的。

延迟-回溯算法

在 Gabriel Gambetta 的 Fast-Paced Multiplayer (Part IV): Lag Compensation 一文中提到了一种“延迟补偿”算法,我个人认为更加适用于大多数 MMORPG 的场景。这种算法不再追求实时,而追求准确。换句话说,它不再在乎玩家看到的是“实时”的画面——既然我们做不到让你看到其他人的实时位置,那么让你看到他们在两秒前的位置如何?

通过这种思路,我们可以很轻松的写出网络同步的算法,它对网络的要求也更低——只需要定期采样位置,每隔一小段时间把采样的位置打包回报即可,无需发出大量的网络包来保持同步。同时,客户端也非常好实现,只需确定好延时,然后在过去收到的网络包中,挑出离当前时间减去延迟时间最近的位置,做一个补间动画即可。

当然,它也有它的问题:你看到的是你的“实时”状态,外加其他人的“过去”状态,就像是你超光速旅行一样。这大部分时候没有什么问题,但当你想和其他人交互的时候——比如你瞄准了对方的脑袋开了一枪——此时现在的你对过去的对手开了一枪,可你开枪的时候你的对手已经不在那个位置了。

这个时候,就需要“回溯”了。服务器收到你的“开枪”请求,需要判断你是在哪个时间点开的枪,并且把你开枪的目标的状态回溯到那个时间点,判断你的射击是否命中。如果命中了,再同步到你对手的客户端,告诉他,你中弹了!

显然,你的对手可能对这种情况感到奇怪:我明明已经走远了,为什么他还能打到我?因为同样地,他看到的是你的过去,而你现在已经走远了……嗯,怎么说,只能接受了。

总结

看了很多游戏的同步算法,可以说,在客观延迟存在且无法解决的前提条件下,各种同步算法在尽力保证本地游戏体验的同时,或多或少都牺牲了一些东西。要么牺牲其他玩家的准确性(预测类算法),要么牺牲实时性(延迟类算法),要么牺牲流畅性(锁帧类)……怎么说,鱼与熊掌不可兼得嘛。

对于我而言,我还是期望人类发明什么超光速无延迟通信方案,这样就可以随便怎么做都很开心啦!

为什么 escape 可以使 btoa 正确处理 UTF-8 编码的字符串?

浏览器在 window 上提供了 atobbtoa 两个 API 用于处理 base64 编码的字符串,然而他们不能处理非 Latin1 字符集的文本:

btoa('hello') // "aGVsbG8="
btoa('你好') // error!
// Firefox: InvalidCharacterError: String contains an invalid character
// Google Chrome: Uncaught DOMException: Failed to execute 'btoa' on 'Window': The string to be encoded contains characters outside of the Latin1 range.

然而,经常看到有人会这么处理 base64 编码的字符串:

function encodeBase64(str) {
    return btoa(unescape(encodeURIComponent(str)))
}
function decodeBase64(encoded) {
    return decodeURIComponent(escape(atob(encoded)))
}

令人讶异的是,通过组合 escapeencodeURIComponent 两个函数,可以正确地将字符串编码为 UTF-8 的 base64 编码:

encodeBase64('你好') // 5L2g5aW9
decodeBase64('5L2g5aW9') // 你好

这其中的原理其实并不复杂。

古老的 escape / unescape

escape 和 unescape 是 ECMA-262 第一版标准中定义(P60 15.1.2.4)的 API,是 The global object 下的一个函数,直到现在也还是 ES 标准中的一部分(虽然已经不是 Web 标准了)。这里摘录最新版的 escape 标准 如下:

The escape function is a property of the global object. It computes a new version of a String value in which certain code units have been replaced by a hexadecimal escape sequence.

For those code units being replaced whose value is 0x00FF or less, a two-digit escape sequence of the form %xx is used. For those characters being replaced whose code unit value is greater than 0x00FF, a four-digit escape sequence of the form %uxxxx is used.

为了避免问题变得复杂,我们在这里先只讨论 Unicode BMP(0号平面)的问题。

根据标准定义,每个 code point 在 0x00~0xff 的(非字符数字、@*_+-./ 的) Unicode 字符将被编码为 %xx,其中 xx 为这个字符的码点 16 进制的表示;而每个在 0x0100 ~ 0xffff 之间的 Unicode 字符都会被编码成 %uxxxx。这在 0x00~0xff 区间和我们所熟悉的 URL 百分号编码(RFC3986)有些相似——都是 %XX 的形式;而在更广的范围内编码结果并不是我们所熟悉的样子。

举个例子:

escape(',') // %2C
encodeURIComponent(',') // %2C

当然,unescape 就是上述规则的反向,就不再赘述。

encode / decodeURIComponent

encodeURIComponent 相对 escape 可是年轻多了,它是在 ES3 被加入标准的。这个 API 和 escape 的最大不同在于,它将 JavaScript 字符串进行 UTF-8 编码,再进行百分号编码。例如一个汉字:

escape('你') // % u4F60
encodeURIComponent('你') // %E4%BD%A0

可以看到,两个编码截然不同,encodeURIComponent 并不是简单的将 % u4F60 拆解为 %4F%60,而是引入了另一套规则。要解释其中的差别,我们就不得不了解字符的“编码”。

编码?

一个 Unicode 字符(更确切地说,已编码字符)的表达方式多种多样。比如我们上文中提到的汉字“你”,它的字符码点(可以理解为编号)是 U+4F60,也可以简单理解为它是 Unicode 的第 20320 个字符。但在计算机中需要对字符进行存储时,则不得不将它编码。常见的编码有 UTF-8, UTF-16 等;而我们的 JavaScript 使用的是一个类似 UTF-16 的编码——UCS-2。UCS-2 可以视为 UTF-16 的前身(或者讲,子集),在我们没有引入码点大于 0xffff 的字符时,可以视为它就是 UTF-16。

UCS-2 表达一个字符时,直接使用 16 位存储它的 Unicode 码点。例如,刚刚我们看到,escape('你') 得到的编码 % u4F60 正是“你”这个字符的码点 U+4F60。

而 UTF-8 作为变长编码,会更加复杂一些。UTF-8 使用 1~3 个字节来表达其 BMP 平面(前面说过了,不讨论别的平面的事情)的所有字符,其编码规则可以参考UTF-8 的编码方式一文。对于汉字“你”,UTF-8 编码后的字节序列为 E4 BD A0,经过百分号编码就变成了 %E4%BD%A0 ,十分合乎逻辑。

Latin-1

那么我们这里又要说说 Latin-1 这个编码了。Latin-1 正式的名字叫 ISO/IEC 8859-1:1998,这个标准使用了 ASCII 留下的 128 个空位,加入了一堆西欧的字符——可能那时候的人类认为有英文字母再加上西欧字母就足够用了吧。通常我们说的 Latin-1 编码,其实还包括了所谓 C0 / C1 控制字符,所以下文说 Latin1 其实是指这个包括了控制字符的字符集。总之,Latin1 加上 C1 控制字符,刚好够把一个字节从 0x00~0xff 一共 256 种可能性全数填满。

这样一种一个字节的所有可能性全部填满的编码,有一个显而易见的特性:任意字节序列都是合法的 Latin-1 编码的字符串,说人话就是,啥都可以被解读成 Latin-1,即使看起来是乱码。

说回 atob

普及了这么多编码常识,终于可以回到重点了。让我们先来看看 Unicode 的编码区块:其 0x00 ~ 0x7F 和 ASCII 一致,而 0x80 ~ 0xFF 和 Latin-1 一致;而 Latin-1 的 0x00 ~ 0x7F 和 ASCII 又一致,那么可以得出结论:Unicode 的前 256 个字符,和 Latin-1 的字符集完全一致!

而 btoa 这个 API 是基于 Latin-1 字符集处理的;它将字符串中的每个字符,转换成 Latin-1 编码,然后按字节对其进行 base64。

这样一来,我们就有思路从 UTF-8 转化到 Base64 了:

  1. 先用 encodeURIComponent 把 js 的字符串转成 UTF-8 的百分号编码形式;
  2. 再用 unescape 把百分号编码按字节转化成对应的含有 Latin-1 字符集字符的 js 字符串 (即使它是乱码);
  3. 最后用 btoa 把只含有 Latin-1 的 js 字符串转换成 Base64 编码。

回过头来看看文章开头的代码,和这个思路一样嘛!

小结

其实说实在的,这个小技巧成功的主要原因还是多亏了 encodeURIComponent;它会按照 UTF-8 编码来处理字符串,给我们带来了很大的操作空间。事实上,MDN 的样例代码就没有使用 unescape,而是使用了正则和 String.fromCharCode 来将百分号编码转换为 Latin-1 字符。在这个页面上,也有一些其他的解决方案能够达到同样的问题。

写这篇文章主要是想讲一些和编码相关的事情,可能平时大家不太在意,或者懂个大概,希望能够比较详细的讲解一下。

另外,为了方便讨论起见,本文仅仅讨论了字符串中只含有 BMP 平面的情况。事实上,由于种种原因,即使字符串中包含了非 BMP 平面的字符,本文的所有结论依然成立——主要还是因为 encodeURIComponent 会按类似 UTF-16 的方式去解析 js 底层实际上是 UCS-2 的字符串,并按 UTF-8 编码起来。

扩展阅读:UTF-8 遍地开花 宣言

zshrc 启动速度分析和优化

由于常年不科学的使用和随便塞东西,我的 .zshrc 里有太多太多的各类语言、SDK 的启动逻辑,因而它逐渐变得不堪重负起来。今天终于受不了了,我决定对它进行整理,移除部分太慢的代码,并且将部分不需要实时加载的东西懒加载。

速度优化的前期准备

要开始优化,首先需要有科学的评估速度的方法,这将使我们能够找到速度的瓶颈。

测算总启动速度

使用 time 命令,可以测算 zsh 的启动时间:

$ \time zsh -i -c exit
        6.62 real         3.23 user         2.29 sys

可以看到,我的 zsh 启动时间需要 6.62 秒,可以说是十分糟糕的速度了。作为对比,不加载任何启动命令脚本的 zsh 启动速度飞快,甚至只需要 0.01 秒:

$ \time zsh --no-rcs -i -c exit
        0.01 real         0.00 user         0.00 sys

设定优化目标

一个合理而可以达到的目标对速度优化也至关重要。今天,我的目标是不修改第三方代码的情况下,将加载时间优化到一秒以内,而尽量不损失任何功能。我们评估时间所用的方法就如同上文所述,以 time 测算的 real 时间为准。

找到速度瓶颈

找到速度瓶颈的方法通常是运行“性能评估”,也就是 Profile。由于 zshrc 是一个 zsh 脚本,我需要寻找一些 zsh 脚本的 profile 方法。

一番查找之后,找到了 Profiling zsh shell scripts 这篇文章。按照文章中描述的方法,我们在 .zshrc 的最前面加入:

PS4=$'\\\011%D{%s%6.}\011%x\011%I\011%N\011%e\011'
exec 3>&2 2>/tmp/zshstart.$$.log
setopt xtrace prompt_subst

在最后面加入:

unsetopt xtrace
exec 2>&3 3>&-

然后执行 zsh -i -c exit 让 zsh 运行一遍初始化。执行完成后,你应该可以在 /tmp 下看到输出的结果:

$ ls -l /tmp/zshstart*.log
-rw-r--r-- 1 oott123 wheel 470745 Jun  9 16:26 /tmp/zshstart.8854.log

这个日志文件已经可以看了,只不过,人类似乎很难阅读……万幸的是,文章的作者也给出了一个工具,用于把输出的文件转换为 KCachegrind 可读的 callgrind 文件。然而,它是 OCaml 写的,也没有提供预编译二进制文件,我们必须先安装 OCaml 来编译运行它。为了速度,那就装吧!

由于我们在 macOS 下工作,通过 brew 可以很轻松的安装 OCaml、opam和 QCacheGrind(这里使用 QCacheGrind,效果和 KCachegrind 应该一样):

brew install ocaml
brew install opam
brew install qcachegrind --with-graphviz

然后使用作者提供的工具 zshprof 将日志文件生成 callgrind 文件,并使用 QCacheGrind 打开它:

git clone https://github.com/raboof/zshprof.git
cd zshprof
opam init # 完了,我们还没开始优化,又有一个程序往 .zshrc 里写东西了……这就是为什么它越来越卡
eval `opam config env`
opam install ocamlfind

# ocamlfind ocamlopt -linkpkg -thread -package str ZshXtraceToCallgrind.ml
ocamlfind ocamlopt -linkpkg Callgrind.ml -linkpkg ZshXtrace.ml -thread -package str ZshXtraceToCallgrind.ml
./a.out < /tmp/zshstart.8854.log > zsh.callgrind
qcachegrind zsh.callgrind

记住这个艰辛的过程,我们在优化途中会经常运行它,以检查优化效果。

打开 QCacheGrind,找到你的 zshrc 文件的源码,点击按 micros 排序:

QCacheGrind 主界面,其中显示了每行源码的耗时。

(由于我个人将自己的所有 rc 代码都写到了一个叫 .shellrc 的文件里,因此这里的截图是 .shellrc 这个文件的。)

很显然,大量类似 dvmphp-version 这样的版本管理工具初始化命令和他们的补全函数消耗了绝大多数时间。我们就从这些函数入手,优化我们的 shell 加载速度。

移除不必要的进程创建

不看不知道,一看吓一跳。就拿下面两句来看:

[[ -s "$(brew --prefix dvm)/dvm.sh" ]] && source "$(brew --prefix dvm)/dvm.sh"
[[ -s "$(brew --prefix dvm)/bash_completion" ]] && source "$(brew --prefix dvm)/bash_completion"

光这两句,就执行了 4 次 brew --prefix dvm,而这个命令在我的机器上执行一次需要 0.7 秒:

$ \time brew --prefix dvm > /dev/null
        0.71 real         0.37 user         0.19 sys

为了加载 dvm 而执行的 brew 就用掉了 4*0.71=2.84 秒,这还不算 dvm 本身的加载时间!而对于我而言,brew --prefix dvm 的结果执行一万次也不会改变,一定是 /usr/local/opt/dvm ——因为我知道我不会移动默认的 prefix 位置。那么,将 brew --prefix 命令都替换为字符串:

[[ -s "/usr/local/opt/dvm/dvm.sh" ]] && source "/usr/local/opt/dvm/dvm.sh"
[[ -s "/usr/local/opt/dvm/bash_completion" ]] && source "/usr/local/opt/dvm/bash_completion"

再跑个分试试:

$ \time zsh -i -c exit
        1.62 real         0.93 user         0.69 sys

哇,真是成效显著,一下就从 6.62 秒缩短到了 1.62 秒,整整减少了 5 秒钟!

移除 brew 之后 QCacheGrind 的样子

重新跑一次 profile,也可以看到,那些明显拖慢启动速度的项目已经没有了。顺便吐槽下:Ruby 和 brew 启动也太慢了吧……

现在,我们还剩下一些别的命令初始化和命令补全的耗时。这些命令看起来很好,只是他们的内部耗时相对较大。

懒加载命令和补全功能

为了不影响功能,又不修改第三方代码内部实现,我们无法很好的优化那些非常缓慢的命令初始化过程。但是,大多数情况下,我不会用到其中大多数命令。因此,我希望实现“懒加载”功能,在我首次使用这些功能的时候,zsh 帮我初始化他们。

懒加载的好处是启动快,缺点是运行的时候会比较慢。但这个慢是分散的,不会全部都卡在启动的那几秒钟里,所以我认为效果损失是可以接受的。那么,怎样懒加载他们呢?

一个很简单的方法是,把命令换成一个占位函数,然后在这个函数中再去执行真正的二进制文件。比如:

dvm() {
    # 移除占位
    unfunction "dvm"
    # 加载真正的 dvm
    source "$(brew --prefix dvm)/dvm.sh"
    # 加载 dvm 的补全
    source "$(brew --prefix dvm)/bash_completion"
    # 执行真正的 dvm 命令
    dvm "$@"
}

(上述代码实现参考自:Speed up initial zsh startup with lazy-loading,基于 CC-BY-SA 使用)

但这样,第一次使用 dvm 的时候,就没有命令补全了!于是,命令补全也用同样的方法做懒加载:

__lazycomp_dvm() {
    # 移除占位
    compdef -d dvm
    unfunction dvm
    source "$(brew --prefix dvm)/bash_completion"
}
compdef __lazycomp_dvm dvm

这样,在第一次按 Tab 的时候,就会加载 dvm 的补全。由于不同命令的补全逻辑不一样,所以还是没法第一次执行补全命令,但多按一次 Tab 键至少比多执行一次命令好多了。

把这两个懒加载方法写成函数:

my_lazyload_add_command() {
    local command_name=$1
    eval "${command_name}() { \
        unfunction ${command_name}; \
        _my_lazyload_command_${command_name}; \
        return ${command_name} \"\$@\"; \
    }"
}
my_lazyload_add_comp() {
    local command_name=$1
    local comp_name="_my_lazyload__compfunc_${command_name}"
    eval "${comp_name}() { \
        compdef -d ${comp_name}; \
        unfunction ${comp_name}; \
        _my_lazyload_comp_${command_name}; \
    }"
    compdef $comp_name $command_name
}

再把 dvm 等不太常用的命令初始化逻辑用这两个函数写出来,比如说这样:

_my_lazyload_command_dvm() {
    source "/usr/local/opt/dvm/dvm.sh"
}
_my_lazyload_comp_dvm() {
    source "/usr/local/opt/dvm/bash_completion"
}
my_lazyload_add_command dvm
my_lazyload_add_comp dvm

搞完之后再跑个分试试:

$ \time zsh -i -c exit
        0.37 real         0.24 user         0.18 sys

哇,已经只有 0.37 秒了。再看看 Profile,大头已经到了 oh-my-zsh 里:

最后的 Profile 结果

打开 zsh 一看,确实感觉快多了。试试那些被我们 lazy load 的命令,用起来感觉也没有什么延迟。优化初见成效,收工。

对了,完成之后记得把一开始我们添加到 .zshrc 中的几行 Profile 代码干掉。否则,它会创建一堆临时文件,并且它本身也会影响一点点速度。

如何让你的 .zshrc 更高效

这次优化,也让我看到平时写 shell rc 文件的一些常见误区,这些问题可能导致 shell 启动慢到爆表。总结说来,主要是这几点:

  • 尽量避免在初始化脚本中调用外部进程,特别是脚本语言的解释器,比如 node, ruby。他们的冷启动时间非常长,长到你怀疑人生。
  • 避免重复执行语句,特别是调用外部进程的语句。
  • 避免增加不必要的初始化代码。比如调用 nvm 初始化 node.js 版本。应当利用懒加载或者 avn 等方式来做这个事情,或者干脆写死环境变量。
  • 尽量多使用懒加载,避免加载不必要的函数。

其实今天本来还想顺便把 .zshrc 给模块化,并加入版本控制的,但调优它本身花掉了太多时间,这篇文章也足够长了,就下次再弄吧。

知乎“分享”功能可能正危及你的隐私

如果你和我一样是知乎和 QQ 的深度用户,你可能对这样的消息格式很熟悉:

一条知乎的分享消息

这是从知乎安卓客户端(因为我是安卓用户,所以本文只讨论安卓客户端的问题,iOS 不清楚,可能一样可能不一样)分享到 QQ 的文案格式。让我们仔细看看这个链接:

https://www.zhihu.com/question/61427877/answer/361977341?utm_source=qq&utm_medium=social&utm_member=YzA1N2VkNTNiYTMyMmMwZDdiODYxYmI0NDRiOWZlYTY%3D%0A

一个分享链接

紫红色的部分就是打开这个页面所需要的链接。里面有两个数字,分别代表问题、回答,这样,这个链接就会打开一个回答的页面。这很好,完全没有什么问题。

接下来,绿色的部分:utm_source=qq。到这里,事情变得很微妙。这表示这个链接是通过 QQ 分享出来的。蓝色的部分:utm_medium=social,这表示这个链接是通过社交平台分享出来的。你可以试试把这两个参数删掉,这并不影响你打开这个回答页面。

最重要的问题在黄色的部分:utm_member=Yz.....%3D%0A。你会发现这个部分很长,而且看上去似乎没有什么意义。真的如此吗?我将简单的演示如何通过这个字符串来获取你的知乎个人页地址。

做一些转换

把这串乱码做 URL 解码,再做 base64 解码。此时,它变成了一串 16 进制字符串——好的,还是乱码。但这串乱码已经有用了,打开 https://zhihu.com/people/c057ed53ba322c0d7b861bb444b9fea6 ,你会看到,它跳转到了分享人的个人页。

感谢兮嘉的出演

这样就完成了一个“从分享链接找到分享人的知乎帐号”的过程。这其实很严重——如果你把链接分享到一个群里,那么当群友打开这个链接时,知乎就知道了你和他们是群友;群友也知道了你的知乎帐号。这是十分明显的隐私信息泄漏。

为了讨论隐私泄漏的问题,我们先来聊一聊追踪是如何实现的。如果你已经了解了 utm 追踪,你可以跳到第二节“这有什么问题?”继续阅读。

追踪如何实现?

链接

万维网用户对“链接”都非常熟悉。简而言之,万维网通过链接联系在一起,通过链接来在不同网站、不同应用之间导航。

一个典型的链接,如同一开始我们提到的那个,是由协议、主机、路径、参数和一些其它部分构成。其中,协议和主机用来确定请求该如何、发送到哪个服务器,而路径和参数则是直接发往服务器,由服务器用于决定展示什么内容。

在分享时,知乎,或者其它别的什么 App,大致都会将当前页面的内容生成一个链接,然后再将链接分享到其它 App 中。

utm 参数

上文我们提到了,参数是直接发往服务器的,而分享的时候可以自定义参数,意味着我们可以通过在链接中加入一些特殊的参数。这些特殊的参数静静的躺在链接里,等待别人点击——而一旦被别人(比如你分享的好友)点击,这些参数就将发往服务器,服务器就知道:哦,这个用户的请求有这些参数,意味着他是从分享链接点击过来的。

而 utm (Urchin Tracking Module)参数是业界通用的、用于处理追踪的参数。通常而言,这些参数并不影响你打开的页面是什么样的(对用户无感知),只是用来统计和追踪。这些参数通常以 utm_ 开头。(扩展阅读:https://en.wikipedia.org/wiki/UTM_parameters

就像知乎做的那样,分享的时候增加了 utm_source=qq 这个参数,其它用户点击的时候,这个参数会被发送到知乎服务器上。这样,知乎的服务器就知道了,你的好友是通过 qq 分享的链接访问的知乎。

追踪的积极意义

如果你理解了前面我所说的内容,你很容易想到为什么大家会热衷于使用追踪技术。产品经理想了解从哪个渠道的分享到达率最高、哪个渠道的转化最厉害,或者可能根据来源渠道给予各种奇奇怪怪的针对性优化(或者负优化……)。甚至说,有一定规模并建立起一定的监控机制之后,如果某个渠道出现了问题,也可以迅速发现。

这有什么问题?

如果说追踪对用户没有感知,并且又有那么多好处的话,为什么我要说知乎正在危及你的隐私呢?事实上,由于链接参数的可见性(对第三方平台、浏览器等工具是可见的),这些参数可能被泄漏给第三方。此外,对第一方(应用开发者,比如知乎)而言,许多所有用户操作都属于用户的个人隐私,需要对用户显式说明。

我将在这里简单的说明几个较为明显、容易想到的问题。这些问题并不一定实际存在——我也不知道他们是不是存在,我只在此提出:这些技术是完全可行的,并且知乎也可以简单的避免这些问题。

跨平台的用户追踪

对于每个用户而言,不管你分享多少次,只要你不退出或者更换帐号,utm_member 是不会变化的。这可以被唯一标识用户,我们称之为“知乎用户标识”——当我们看到这个用户标识,就知道它是你。虽然这个链接是 https 的(这样运营商和中间服务器不会看到链接),但除了你和知乎,还有别人可以获取这个链接。

比如你分享到 QQ 上,那么 QQ 就可以知道你的知乎用户标识;你把链接复制到微博,那么微博也知道了你的知乎用户标识;你用浏览器打开,浏览器也知道了你的知乎用户标识。第三方平台完全可以把你在他们平台上的帐号和你的知乎用户标识关联起来,实现跨平台追踪。

可被任何人识别的用户标识符

文章的开篇部分我简要展示了如何从分享链接中获取你在知乎的用户信息。这事实上没有任何技术门槛,任何人都能进行尝试(如果你对技术一无所知,你可以搜索“在线 base64 解码”、“在线 URL 解码”,能搜索到很多在线工具代劳)。假设你在知乎上是超级大V,但身边的人都不知道、你也不想让他们知道。但现在,只要你通过安卓知乎客户端从 QQ 上分享任何内容给你的朋友,大家都知道你就是知乎大V了……

应该怎么办?

相信你已经理解了问题是如何存在的(如果没有理解那就算了……)。那么如何避免这种隐私问题呢?这里我也从用户和运营方(知乎)的两个方面来思考应当如何处理。

用户怎么办

对用户而言,移动端事实上缺乏这类隐私防范的工具。就当前这个事件来看,我发现通过“复制链接”的方法可以获得相对干净的链接,从而摆脱 utm 的追踪。

在桌面浏览器上,我推荐有些隐私洁癖的朋友使用 Neat URL 这个扩展。它可以移除很多无用的 URL 参数,并且有丰富的自定义功能。(利益相关:我是一个 Firefox 用户,我也推荐每个人使用 Firefox,所以不知道 Chrome 下应该怎么办~)

知乎怎么办

知乎应当从整体考虑放弃追踪特定用户的分享行为。如果你非要那么做,至少请:

  • utm_member 字段每次分享都不一样(避免追踪特定用户)
  • utm_member 字段无法被第三方轻易反解出用户唯一标识

举个我认为比较好的做法:在客户端随机生成一个唯一字符串,将该字符串发送到服务器上,让用户与其关联。每隔一段时间(比如 4 小时)重新生成该字符串。

总结和反思

事实上在发现这个问题之后,我有和一些朋友描述我的发现,并且也试图向知乎反馈该事件,但并未得到什么响应。我想,大概从知乎到用户,所有人都不太理解这为什么对隐私有所伤害。

当然,知乎和国内大部分 App 一样,在隐私方面的问题远不止我提及的这一点,只是这一点非常容易验证而又十分明显,才这样公开的提出来。知乎在国内的移动市场上算是比较有情怀的(起码以前这样……),也希望知乎能够有足够的内部力量推动关注这些事情。

国内在隐私方面的关注实在是太少,而又很难有合适的渠道来了解、学习这些和自己生活息息相关的事情。这篇文章或许有不少片面或者理解不到位的地方,也请各位指正。

Safari 下同页面创建的视频超过 16 个则无法播放

最近在做一些视频相关的东西。在 Safari 下发现这样一个问题:<video> 标签创建多了,会导致同一个页面内后续的视频都无法播放。不管是 iOS 的 Safari,还是 macOS 下的,都存在同样的问题。尤其是 iOS 下,超过 16 个的视频几乎必定无法播放;效果就是显示一个播放按钮并且有一条斜线。

无法播放的视频截图

通过 video.error来检查最后的错误,得到的是 MEDIA_ERR_DECODE;同时,video.play() 方法会 reject 一个 AbortError。在 Google 检索了很久也没有得到有用的信息。

为了演示这个 bug,一个简单的示例的源码被我放在了 gist 上。你可以通过在 Safari 上打开 RawGit 提供的链接 来测试这个 bug。你只需要点击“测试”,并在视频开始播放后再次点击“测试”,如此循环 16 次,便能得到和我上一张图片一样的结果。

示例的代码如此简单,以至于我认为这个 bug 是几乎不可能发生的;它仅仅是调整了一个 DOM 的 innerHTML 来创建一个 video 元素(事实上无论通过何种方法创建,都会有这个问题;playsinline 属性似乎是必要的),然后再删除它,循环 16 次而已。

hello.innerHTML = '';
var src = 'https://cdn.rawgit.com/mediaelement/mediaelement-files/4d21a042/big_buck_bunny.mp4';
hello.innerHTML = '<video id="video" webkit-playsinline playsinline controls src="' + src + '">';
setTimeout(function () {
    document.getElementById('video').play();
}, 500);

目前,我没有发现任何能够解决这个 bug 的方法。可能唯一的办法就是,播放 15 个视频之后,刷新一下整个页面吧。

为熊猫 TV 直播生成带弹幕的录播视频

最近在沉迷谜之声直播。由于有时候下班比较晚,赶不上开播,又偶尔对直播游戏感兴趣的,就会跑去找录播来看。这个主播十分好心,会在贴吧里上传自己的录播视频。然而由于主播自己的录播视频采用 obs 串流的同时录制,对弹幕肯定是无能为力的,于是就想着能不能通过什么办法把弹幕加上去。所幸,办法还是有的。

现在熊猫直播全站 https 后,弹幕数据都是走的 websocket 了,通过 Fiddler 之类的抓包工具可以轻松抓到。不过,知乎上有一篇详细的回答说明老版本弹幕格式,也有一些现成的项目用来解析弹幕,所以这里就不关注弹幕数据格式了。总而言之,我把这些东西封装成了一个 npm 库 pandan,可以通过 node.js 简单的获取一个房间的实时弹幕了。比如:

let room = new (require('pandan').Room)(426346)
room.on('room-danmaku', function ({data}) {
  console.log(`${data.from.nickName}: ${data.content}`)
})
room.join()

有了弹幕数据自然就好办多了。从抓到的数据包来看,有主播开播、下播事件,送礼、送竹子等事件,完全足够我生成弹幕了。通过一段小脚本,可以把弹幕格式转换成下面这个样子的 xml:

...
<d p="1830.6300048828,1,25,16777215,1415241251,0,Da8b0a0d,668464499">再见了我的青春</d>
<d p="1477.6800537109,1,25,52479,1415241254,0,35b92ad0,668464527">虐啊</d>
...

脚本比较丑,就不贴了,相关参数的解析可以看这篇文章。这里有个小问题,由于直播通常有几秒钟的 buffer,所以弹幕整个时效性会稍微延后一些。这个问题在你观看直播的时候不容易发现(因为大家都有延迟),但生成录播之后弹幕会很明显的延后。当然,解决起来也十分简单,将弹幕时间轴往前平移几秒钟就可以了。

然后祭出大杀器:danmaku2ass,一个能将弹幕转换成 ass 字幕的工具。执行类似这样的命令:

python3 danmaku2ass/danmaku2ass.py -o 2017-07-17-1942-09.ass -s 1152x648 -fn "Microsoft YaHei" -fs 30 -a 0.8 -dm 7 2017-07-17-1942-09.xml

就能生成 ass 字幕了。使用任何带字幕功能的播放器,都可以播放啦~看个例子:

example image

当然,并不是所有主播都会自己上传录播视频。如果你刚好也有这种需求,可以尝试在连接弹幕服务器之后,获取开播事件,并在开播之后通过 you-get 来获取直播录像。

User Mode Linux – 一个用户态的 Linux 内核

User Mode Linux 可以在用户态启动一个 Linux。这使我们能在类似 OpenVZ 虚拟化技术的系统上,使用最新的 Linux 内核;甚至可以在非 root 用户下启动。

但有些 OpenVZ VPS 的 TOS 可能不允许你这么做;另外,这样可能会有一定的性能损失。

准备 rootfs 镜像

我们找一台 Ubuntu Server 来准备 rootfs。这个时候需要有 root 权限,因为你需要 mount 一个 loop 文件系统,chroot 改点东西之类的。

# 下载镜像
wget http://uk.images.linuxcontainers.org/images/alpine/3.1/amd64/default/20170305_17:50/rootfs.tar.xz
# 安装解压软件
apt install xz-utils
# 创建镜像,32 M 对于 alpine 是够用的
dd if=/dev/zero of=rootfs.img bs=1M count=32
mkfs.ext4 rootfs.img
# 把镜像挂到 loop 上
mkdir rootfs
sudo mount -o loop rootfs.img rootfs
sudo tar xvf rootfs.tar.xz -C rootfs
# 给 root 设个密码
sudo chroot rootfs /bin/sh
  passwd
  exit
# 清理
sudo umount rootfs
rmdir rootfs

此时 rootfs.img 已经做好了。

编译内核

我们继续用 ubuntu server 来编译 UML 内核。

sudo apt install build-essential kernel-package fakeroot libncurses5-dev libssl-dev ccache
# 抓内核源码
wget https://cdn.kernel.org/pub/linux/kernel/v4.x/linux-4.10.1.tar.xz
tar xvf linux-4.10.1.tar.xz
cd linux-4.10.1
make defconfig ARCH=um
make menuconfig ARCH=um
# 这里会出来一个界面让你配置内核,有茫茫多的参数可以选
# 大概找一些 linux 内核编译的文章可以参考吧
# 主要是 User Mode Linux 有一些特殊选项可以注意下,比如强制静态编译什么的
# 然后就开始正式编译啦
make ARCH=um
strip linux # 移除调试符号

编译过程可能比较慢。有时候会有一些库找不到啥的,apt 装一下就好。

获取 slirp

slirp 是用来在没有 root 的情况下联网的。

apt source slirp

这里获取到的 slirp 放到目标机器上编译,不需要在 ubuntu server 上搞了。

cd slirp-1.0.17/src
./configure
make CFLAGS="-I. -DUSE_PPP -DUSE_MS_DNS -DFULL_BOLT -fno-strict-aliasing -Wno-unused -std=gnu89" clean all

启动 UML

现在万事俱备,只欠东风了。

# 在 UML Host 上
# 把一些二进制文件放好
slirp=/home/oott123/uml/slirp/slirp-1.0.17/bin/slirp
uml=/home/oott123/uml/kernel/linux-4.10.1/linux
rootfs=/home/oott123/uml/alpine/rootfs.img
cow=/home/oott123/uml/machine/demo/data.cow # 放 copy-on-write 数据的地方
export TMPDIR=/tmp
$uml rw ubda=$cow,$rootfs mem=128M eth0=slirp,,$slirp

配置网络

启动之后你可能会发现没有网络。不用担心,改改网络配置:

# 在 UML 里
vi /etc/network/interfaces
#####
auto eth0
iface eth0 inet static
address 10.0.2.15
gateway 10.0.2.2
netmask 255.255.255.0
dns-nameservers 10.0.2.3
hostname $(hostname)
#####
/etc/init.d/networking restart
echo 'nameserver 114.114.114.114' > /etc/resolv.conf
# 测试网络
wget -O- myip.ipip.net
# 安装软件
apk update
apk add curl
curl myip.ipip.net
# 关闭 UML
halt

与主机交互

或许你需要将 UML 内的端口转发到主机上。

# 在主机上
vi ~/.slirprc
redir 2222 22

或许你需要访问主机的文件系统。

# 在 UML 上,把 UML 内的 /mnt/home 映射到 /home
# 注意权限问题、注意两边不要同时读写
mkdir /mnt/home
mount none /mnt/home -t hostfs -o /home