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

最近在想开发一个多人的 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 是一个 bash 脚本,我需要寻找一些 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

2016: 我的浏览器 A – Z

A: https://aoishi.[REMOVED] 自己服务器的管理面板
B: http://bbs.i-cassell-you.com/forum.php 还是卡院
C: http://civitas.soobb.com/ 多少年没上了……
D: https://www.dnspod.cn/ DNSPod 有几个域名在上面
E: http://easy-admin.[REMOVED] ???已经是我记不起来的东西了
F: http://fiddle.jshell.net/ 呃……其实我用得不是很多
G: https://gitlab.com/ Ainou 的代码托管在那,所以经常去
H: http://hicivitas.com/ 以前兼职公司的官网
I: http://www.ip138.com/ …………大概是经常看自己 ip
J: http://jd.com/ 已经不怎么上淘宝了,狗东比较舒服
K: http://kuler.adobe.com/ 一个配色工具,可能是因为在收藏夹吧,平时不怎么用
L: http://logcg.com/ 这是谁的博客啊……完全不记得访问过了
M: https://mp.weixin.qq.com/ 微信公众平台……嗯最近可能是上得比较多
N: http://notes.jetienne.com/ 这又是谁的博客了……不记得了
O: http://openshift.redhat.com/ 我感觉今年我应该没上过
P: http://piao-tech.blogspot.com/ 怎么越来越离谱了
Q: http://qzone.qq.com/ ……啥。。
R: http://redis.io/ 嗯……倒是经常去查文档
S: https://suiyu.xyz/ 偶尔会写日记
T: http://tower.im/ 好用的团队协作工具呢……不过现在也不怎么用了
U: http://upyun.com/ 偶尔会上去配一下 CDN
V: https://v2ex.com/ 毫无悬念
W: http://weibo.com/ 其实我最近比较少上了……
X: http://xiumima.com/ 呃……社工库么
Y: http://yeah.net/ 偶尔会上网易邮箱看看
Z: https://zhihu.com/ 知乎倒是常去看的

总之,通过浏览器,可以看出一个趋势:我使用浏览器浏览不同的 web 网站的时间越来越少了——大部分网站我根本想不起来。而更多的时间,可能花在了刷手机上。移动互联网近年来确实是势不可挡,我也不得不沦陷了。

唯一不变的,大概是 Web 的信仰——以及,今年我还在坚持使用 Firefox 浏览大部分网页。

清空 Mac QQ 的最近表情列表

更新 Mac QQ 之后,最近发送过的表情变成了一大片惊讶,如图:

惊讶的 Mac QQ

不管发什么表情这些顺序都不会变,简直坑爹。去 QQ 的存储目录找了一下,发现删掉以下文件后就会好:

HistoryFacesStack_V52.archive
HistoryFacesList_V52.archive

这些文件位于 ~/Library/Application Support/QQ/(QQ号)/CustomFaceConfig.db(非沙盒)或 ~/Library/Containers/com.tencent.qq/Data/Library/Application Support/QQ/(QQ号)/CustomFaceConfig.db(沙盒)下。

删了以后,重启两次 QQ,果断好了:

不再惊讶的 Mac QQ

Unmanaged Exports 在中文系统下报错 syntax error at token ‘{‘ 问题

UnmanagedExports 是一个可以在 C# 下生成类 c api 的 dll 的工具。

不过这玩意儿在中文系统下编译,可能会产生如下报错:

C:\Users\Administrator\AppData\Local\Temp\tmp6C70\adddll.il(60) : error : syntax error at token '{' in:   { AddDll

解决方法很简单,换成英文系统即可。

至于啥原理,我也不知道。写下来避免后人踩坑……如果有哪位同仁知道怎么回事,也请告诉我~