IRedis开发记3:编译正则的难题

这篇文章是 IRedis 的第三篇开发笔记。一直以来,IRedis 的补全都是基于 prompt_toolkit 的 regular_language 来实现的(一个例子)。我需要用正则表达式来验证用户输入的 Redis 命令是否合法,从中抓出来 token,然后对这些 token 进行自动补全。随着开发,支持的 Redis 的命令越来越多,这个正则表达式已经膨胀到 200+ 行了,编译速度也令人难以忍受。

这个问题困扰了我将近3个月,我尝试过各种各样的方法,从身边的朋友那里听取不同的思路和建议,终于在最近近乎完美地解决了这个问题。以至于兴奋的睡不着觉。

这篇文章分享一下这个问题,为了解决这个问题尝试过的方案,以及最终采用的方案的工作原理。读者从这个问题和方案本身可能并不会学到什么可以 Take away 的东西,因为很难在遇到这种问题(这也是这个问题比较难解决的原因之一)。但是我更想分享一下解决的思路,以及它带给我的启发。可能对于将来的我,再回来看的时候,会发现解决问题是如此美妙的一个过程,编程本身又是多么充满乐趣的事情。

问题:用正则来匹配输入……

为了接下来的讨论,我先描述一下我面临的问题。

我要写一个 Redis 命令行的客户端,支持对 Redis 命令的自动补全。(相关文章 IRedis 开发记录:Redis 命令语法的处理 )。但是我不会从头开始写这个命令行客户端的输入输出,而是选择了 IPython 和 mycli/pgcli 都用的一个库:prompt_toolkit. 它是用了 Python 的正则表达式的 named group 功能,从输入的内容中抓出来 token。比如这个例子中,语法的正则是这么写的:

当输入 abc  (注意后面有个空格)的时候,abc 就匹配到了 <operator1> ,然后空格匹配到了 \s+ ,框架就知道后面要输入的是一个 var1 了。

类似的,我写的 Redis 的命令节选如下:

因为 Redis 的命令不像 SQL 那样是结构化的语法,每个数据类型的命令格式都不一样,所以写起来要用或 | 将这些语法都连接起来。

另外还要补充一点的是,我写的这个正则并不是直接拿去匹配用户输入了,因为框架需要知道这个 token 的下一个 token 可能是什么,以此来做自动补全的提示。所以其实我的这个正则是交给框架去解析了,而不是直接 re.compile() 。框架会将这个正则拆散,然后一个一个编译。这一点也导致了后面会讲到的一些方案行不通。

另另外要补充的是,不光是拆开这些正则就可以了,如果这样到好了,也就是不到 100 个正则,编译一下也是很快的。作为用来匹配用户输入的问题是,即使用户只输入了一部分,也应该认为是合法的。比如正则是要匹配 SET 命令,而用户输入了 SE,那么也应该认为这是一个合法输入,而不应该显示成下图这样的非法提示。

那么怎么做到这一点呢?这个框架的方案是解析正则,然后分析出更多的正则来匹配部分输入。比如 SET,那么根据此应该生成 SET|SE|S,看起来也不复杂,但是如果支持的参数越来越多,需要编译的正则的数量将会呈指数级上涨。

举个简单的例子,下面这个语法:

最终生成的正则是这样子:

这就很恐怖了。

当 IRedis 支持 Redis-server 的 197 个命令语法的时候,需要编译的正则已经达到了 ~8000 个。IRedis 启动的时候需要等待 ~8s 编译正则完成。

这就是问题了。作为一个命令行客户端(对比一下 mysql/redis-cli/mongo 这种客户端),需要等待 8s 才能使用,是非常影响体验的。

缓兵之计:异步线程加载

这个问题没解决之前,我将这些正则的编译改到了一个 daemon 线程中去编译(这个PR)。在编译结束之前,最下方的 bottom-bar 会显示一个 ascii 动画提示正在编译,并且 Lexer 高亮,补全这些功能都没有激活,像一个 redis-cli 那样只有基本的功能。

但是这段时间 CPU 在不停的做正则编译,占用量会很高。以及用户体验也有影响,相比 redis-cli 秒开,太伤了。

 

如果要从根本上解决这个问题,现在看来有以下几个思路:

  1. 能否缓存下来正则,不要每次都编译?
  2. 能不能替换掉编译正则的方案?
  3. 能否加速正则的编译?
  4. 减少需要编译的正则的数量;
  5. 不要一下子编译好所有的正则,用到哪个编译那个。

尝试1:缓存正则

这是一个比较直觉的方案。我尝试过用 pickle/dill (应该还尝试过一个,不过名字忘记了)。发现 dump 出来,下次启动直接 load,从方案上是可行的。但是速度依然很慢。后来发现 pickle 只是去打包一个对象的 __init__ 参数,当 load 的时候,再用类初始化一次(这样对于大部分 pickle 的使用场景来说是合理的)。所以还是相当于编译了一次。(资料

理论上,直接缓存 C 语言层面的 regex 编译结果也是可行的,但是会遇到 Python 跨版本不兼容的问题,因为这部分并没有公开的 API,所以没有人能保证它的兼容性。

我觉得这将会是一个大坑,所以没有继续沿着这条路走下去了。

尝试2:另寻没有正则的方案

老实说,用正则来处理语法并不是很好。比如 Redis 的命令有一些是下面的这种语法,而这种是无法用正则来表达的:

ZINTERSTORE 这个命令 后面有个 numkeys 表示后面紧跟着几个 key,然后再是 WEIGHTS。只有知道了 key 的数量,才不会将 WEIGHT 也作为 一个 key。而正则是不支持从前面解析出来数字然后应用到后面的 match 中的。

于是我想拿 pygments 来做一个基于状态机的 Grammar。参考 这个PR。理论上是可行的,但是并没有最终采用这个方案。原因是工作量太大了。如果我直接用正则,那么我可以用 prompt_toolkit 框架中的一些补全,Lexer。否则的话,我就要重写 Grammar,match 前缀的逻辑,Lexer,判断下一个 token 可能是什么来推测补全列表。相当于另外写一个 prompt_toolkit 了。所以这条路也放弃了。

Jonath(prompt_toolkit的作者)建议过我用状态机实现,这个方案同样也是工作量太大。本来 IRedis 就是因为另一个项目而生的,我不太想因为 IRedis 要先去完成另一外一个新的项目 :)

尝试3:编译更快的正则

这个方案比较好理解,Python 的 re 比较慢,能否用更快的 re 来替换呢。Python monkey patch 比较简单,我只要将系统 buildin 的那个 re 替换掉就好了。大部分其他 re 的库也是兼容 buildin re 的 API的。

我尝试过的库有:

  1. google 的 re2
  2. Rust 写的 rure
  3. Python 的 regex

效果不是很好,原因很简单:这些库可能在一些情况下编译正则的速度快一些。但是我这里的问题并不是编译一个正则太慢,而是要编译的正则太多。对于 8000 个正则来说,就算能快上一倍,效果也不明显的。

尝试4:减少编译正则的数量

guyskky 提到可以将正则 merge 一下,类似 werkzeug 处理 URL 的方式(werkzeug.routing.Rule.compile)。我觉得这个比较可行,但是还没有尝试。

但是跟 Jonath 讨论之后,Jonath 开始优化这一块正则的 merge,这个 PR 到我写这篇文章的时候,已经 work 了。

经过优化之后,需要正则的数量降低到了 496 个,1.7s 就可以编译完成。

尝试5:即时编译

上面的方案4是一个治本的方案,但是在我这个方案之前,我并不是方案4是不是可行的(因为对 prompt_toolkit 读的代码不多),所以我上周用“即时编译”的思路进行了重构。

这个想法很简单:既然启动的时候编译那么多正则表达式很慢,那么我为什么要一开始就全部编译好呢?用到哪一个再编译哪一个不就好了吗?

比如用户输入了 KEYS 那么我就编译好 KEYS 的语法,然后将当前的语法替换成这个。当用户输入了 GET,我再编译 GET 的语法并进行替换。

能够这么做基于两点:

  1. Python 在运行时动态地替换一个对象的属性非常简单,并且我这个 client 也是单线程的,不会存在线程安全问题;
  2. Redis 的不同命令的语法都是独立的,不像 SQL 那样,你不能只编译一部分。当然,问题的根源也是它。

核心代码是下面这个 Processor,Processor 是框架的一个概念,每次用户按下什么键都会执行。

核心代码是 24-27 行,当用户输入的命令匹配到 Redis 命令了,就替换当前的 Lexer 和 Completer。

这样问题就完美的解决了,当然还有一些细节问题。

比如当用户输入了一个命令语法比较复杂的时候,会感觉到一点卡顿,因为在编译正则。我在编译的函数上加个一个装饰器 @lru_cache(maxsize=256) ,将编译结果直接缓存下来,这样只在第一次输入的时候会卡,后面就好多了。

等 prompt_toolkit 那个 PR 合并,配合我修改的“即时编译”,这个问题就算完美的解决了。

 

解决这个问题,大概花了 3 个月。但是最终解决的那一刻,感觉真的很奇妙。这件事情给我一些很强烈的感受:

  1. 编程这件事情本身,我是说就算没有带来任何名誉、财富上的收获,也实在太有意思了;
  2. Python 社区的人太好了,除了 Jonath 给我很多很多建议,Dbcli 的 amjith 还和我视频通话,跟我说他之前也想做一个 redis 的 cli,跟我说了一些他的想法,非常有用。此外还得到了很多很多其他人的帮助和建议。Python 社区很温暖!
  3. 用 Python 编程太快乐了!比如本文的 @lru_cache ,这一行能完美解决问题!IRedis 项目中,我遇到了很多类似的体验,我打算单独写一篇文章讨论这个。

Happy hacking!

 

2019年年鉴

这一年不知不觉又过去了,一年很长,但是到了年底,能回忆起来的事情不多。今年没有什么大事发生,还是一如既往的工作,生活。这篇文章,就来回忆一下今年的屈指可数的收获吧。本质上我写这篇文章是出于习惯,所以你阅读本文基本上不会有什么收获,建议不要浪费这个时间。:)

要说进步,今年值得表扬自己的是,一些存了很久的疑问,今年逐渐一个一个给了自己满意的回答,比如 Linux 分区是什么工作的文件系统是什么工作的,什么是 Daemon 进程等等(写了这么多 Linux 的文章,感觉这个博客需要开一个 Linux 分类了)。以及一些由于不明白而困扰自己的很幼稚的问题。这些答案基本上都来自 Linux System Programming 这本书,细说起来这本书是从 5 月份就开始读的,快到年底了才读完。如果你和我一样对于 Linux 有很多懵懂的问题的话,也推荐你读一下这本书。上一次给我这么大启发的书,还是 Fluent Python 了。作者(Family Name 竟然就叫 Love)具有丰富的 Kernel 开发经验,解释问题比较深入浅出。缺点就是书中解释各种 Error 含义的篇幅有点过多了,都是手册里面的内容,感觉有凑字数之嫌。

除了 Linux,今年还看了一下 Scheme(The Little Scheme)和 Elixir (The little Elixir & OTP Guidebook-Manning)这两本书,都是入门级别的。这两门语言和 Python 有很大的不同,比如 Elixir 有模式匹配,函数内可以写 when 来检查参数,Atom 的类型等等,也给我一些启发。

工作

今年上半年还是一种很不好的工作状态,每天的工作中,和同事沟通占用了大部分的时间,SRE 像是一个技术支持的工作,不断的协调,和不同的部分解释沟通等等。手里同时会被分好几件事,分身乏术。客观上说,总是感觉自己的工作没有意义,学到的东西也不多。

下半年组织结构调整,被调到了另一个组,做一个定位系统故障的系统。简单来说,就是业务不正常了,我们开发的这个系统要能自动定位到是一台机器的问题,还是一个机房的问题,还是数据库的问题,给应急提供一些辅助信息。项目语言是 Java 写的。因为我在之前的组中也尝试过这种定位系统,没有特别明显的结果,之前公司中失败的案例也不少,所以一开始对这个项目是没有信心的。但是做了半年,逐渐有效果了,有一些思路可以自己实现,觉得还是能取到一些成果了。

说来可笑,这几乎是入职以来,第一次能够专心写代码的时候。虽然到底要怎么做还不是很清晰,很多地方要自己摸索,但是工作状态相比之前是好了很多。

另外我们组是用 Java 来开发的,这门语言挑战了我的认知。我写 Python 的时候,每一行代码我都知道是做什么的,现在写的 Java,有很多代码看起来匪夷所思。比如 NPE,像 Python,JavaScript,如果你从 None/Undefined 试图获得一个属性,这些语言都会提示你 xxx 并没有这个属性。Java 呢?NPE。foo.getA().getB().getC(),NPE;call(foo.getA(), bar.getB()) 每当看到 NPE 这个错误的时候都让人痛苦万分,我要为这个简单的错误花很长的时间复现、排查。类似的例子还有很多。

总体上说,我觉得用 Java 开发项目的这段时间,我并没有因为这门语言学到什么启发性的东西(不像Elixir,Scheme)。在我看来,很多 Java 里面才有的东西是纯粹为了 Java 语言本身的问题,放到其他语言上很可能不需要这么做。我这么想也可能是我对这门语言学习的不深,使用的时间不长。以后这个想法也可能改变。

开源和社区

捕蛇者说。这是今年做的一件事,由 laike9m 发起的。Adam,Manjusaka 我们几个人一拍即合,就开始搞了。搞起来网站,在 V2EX Twitter 发了几个帖子宣传,然后开始录制,剪辑。现在看来这件事情是比较可持续发展的,除了剪辑的工作外,不会太让人疲惫,应该不会像 HZPUG 那样一直鸽掉了。今年一共录制了 10 期,感觉还会有源源不断的话题,接下来一年的发展我比较有信心。这件事情对我来说,最大的收获是结交的朋友了,要说学到什么,可能没有读书那样性价比更高。大家交流更多的是一些软性的东西,可以让你知道这么做一件事对不对,一条路是不是对的,类似这样。

PyCon。今年在 PyCon 做了两次演讲,个人觉得,内容比较浅薄。但是后来经常有人跟我说 Migration 那个演讲很好,还有人跟我讨论他们公司的 Migration 的一些方案,很高兴。

开源项目。业余时间,今年主要在做两件事情。第一个是 clock.sh(尚存在一些问题,个人认为还没有到稳定阶段,但是大家可以试用) 。这个项目就是想托管一些个人的定时脚本,没有很大的野心,做一个个人的 Saas,小而美,就够了。至于这个项目的最开始的动力,其实是我之前写爬虫的时候,我们有一个项目叫 xxx-control,里面有一个很长的 crontab 文件,用来管理爬虫什么时候启动执行,那时候我就想有一个更友好的定时任务控制的平台。这个项目我一直按照我的想法去做,半年之前有一个可用的 demo 版本,能通过在网页上简单的点击记下,就设定好一个定时任务了。后面有些慢 SQL 的问题,以及支持自定义 Docker 镜像的问题,一直没有时间解决。

第二个项目是 IRedis,这个是由于第一个项目用 Redis 作为 broker,而我又不太熟悉 Redis 那么多命令。所以就像要一个像 mycli/pgcli 那样的命令行工具。于是就自己开始写了一些。陆陆续续地,IRedis 支持的命令也越来越多,希望能在这个项目1周年的时候发布 1.0 版本。

我的第二个项目是为了解决第一个项目的问题而诞生的,此外还因为用到一些第三方库有问题,去给他们提交了一些 Pull Request。其实今年还有很多很好玩的想法, 精力有限,只能放弃那一些了,先把已经开始的项目做好。

工作上比较忙,正常晚上到家就10点了,所以自己的想法一直苦于没有时间去实现。现在的工作方式比较不健康,我还是想有一个正常的作息时间。

其实这么长的工作时间对公司来说,也未必是一件好事。依我个人看,会减少我对工作的热情,阻碍员工的发展。一周工作 40 个小时的时候,我还是盼望着上班的。太长的工作时间,客观上看,大部分时间也都被没有意义的事情给浪费了。另外很多公司不明白的一件事情是,如果给员工提供的是这样一种环境,那么大部分人的想法将会是不会在这一家公司工作很久,等自己能力提高立马换一家工作和生活更平衡的公司。如果没有时间学习(个人认为真正能学到新的技能的工作岗位很少),那么员工的价值也不会提高。为什么这些公司可以花很多钱从外面招厉害的人,而不愿意培养自己的员工成为那样的人呢?

如果我将来开一家公司,我一定不会认为工作时间长的员工是好员工。相反,我会看他们是否对技术有热情,是否有对自己不了解的事情有求知的渴望,无论这些事情是古老的技术还是新潮的技术。对于刚毕业的学生,一定要想办法减少他们花在没有意义的工作上的时间,一定要有至少20%的工作上的时间自由支配用于学习,一定不要给他们灌输:程序员必须学管理,程序员的“软技能”很重要,以及鸡汤。健康、可持续的生活和工作方式,比焦虑,没有意义的努力和长时间的工作要好一些。

未来的打算

  • 今年一年看的好代码太少了,需要学习一下 debug 工具链,多阅读好的代码;
  • 掌握 Elixir,我觉得这门语言很不一样,尤其是自带 supervisor,9 个 9 的可用率,比较吸引我;
  • 了解更多有关分布式系统,数据库的内容;
  • 锻炼身体,改善生活方式;

 

往年:

  1. 2013年
  2. 2014年
  3. 2015年
  4. 2016年
  5. 2017年
  6. 2018年
 

Daemon Process

本文介绍一个从 Linux 的 shell 诞生的进程,要经历怎样的“考验”,才能成为一个 daemon 进程。

后台进程,顾明思议,在后台执行,没有终端,没有 Login shell。当某些 Event 发生的时候进行处理,或者定期执行某项任务。通常,daemon 进程以 d 结尾,但不是必须的,比如 Redis 和 Nginx 的 daemon 进程就没有以 d 结尾。

后台进程最明显的特征,是 shell (通过 ssh 或者 terminal app 打开的终端)退出之后,后台进程不会退出,而是继续运行,提供服务。

简单来说,daemon 需要具备以下两项基本条件:

  1. 是 init 进程的子进程;
  2. 没有连接到任何 terminal;

此外,daemon 进程通常还会做以下几个事情:

  • 关闭所有的 file descriptors,除了 input, output, error 这三个。因为这些 file descriptors 可能是 shell,或者其他进程。而后台进程最关键的就是不连接 shell 和 terminal。可以使用 open_maxgetrlimit syscall 获取当前打开的最大的文件描述符,依次关闭。也可以遍历 /proc/self/fd 下的文件,依次关闭。
  • 将 working directory 切换到 / 目录。daemon 的生命周期一般伴随整个操作系统的工作时间,如果一直在继承自父进程的 working directory 工作的话,就会影响操作系统运行期间的文件系统 mount 操作。某些进程也可以切换到自己的特定目录工作;
  • 将 umask 置为默认值,通常为 0。因为 daemon 进程创建文件的时候,会想自己设置文件的权限,而不受 umask 的干扰。如果使用的第三方库的话,daemon 可以自己设置 umask 的值,自己限制使用的第三方库的权限;
  • 离开父进程的 process group,这样就不会收到 SIGHUP 信号;
  • 离开 controling terminal,并确保以后也不会再被重新分配到;
  • 处理 SIGCLD 信号;
  • 将 stdin stdout stderror 重定向到 /dev/null。因为后台运行的进程不需要用户输入,也没有标准输出。这样也可以确保当其他用户 login shell 的时候,不会看到 daemon 的输出。

这是 daemon 进程通常会做的事情,man 7 daemon 中有更详细的描述。 接下来,主要讨论 daemon 最精彩的部分,即如何通过两次 fork() 来完成脱离 terminal。

两次 fork()

前面介绍了一些比较简单的处理,比如 chdir,reset umask。接下来讨论如何脱离 terminal。

为了方便读者理解,我先画一张图,并标出每一步动作发生了哪些变化,然后再具体解释。

Shell 创建进程的过程如上图。这里先解释一下 4 个概念:

  1. pid 是什么?进程 ID,一个进程最基本的标志。创建新的进程的时候 kernel 会分配一个 pid。
  2. ppid 是什么?创建此进程的进程,即父进程,这里就是 shell 的 pid,因为进程是从 shell 创建的。
  3. sid 是什么?sid 指的是 session id,本文不作过多介绍,读者可以认为是和 shell 在一组 session 的进程,这样 shell 退出的时候会给 session leader id 为 shell id 的进程都发送 SIGHUP,将自己产生的子进程都一并退出,方便管理。所以,新创建进程的 sid 也是 shell pid,自动加入 shell 的 session。
  4. pgid 是什么?pgid 是 process group id,是一组进程id。考虑这种命令:grep GET accessl.log | awk '{print $1}' | sort | uniq ,如果我们想结束这个命令的时候,不会想 grep,awk.. 这样一个一个的结束,而是想将他们一次性全部结束。为了方便管理,shell 会将这种管道连接的进程置为一组,这样可以通过 pgid 一并结束,方便管理。所以,新创建的进程的 pgid 是自己,它自己也叫做 group leader。

第一次 fork()。 调用 fork(),父进程立即退出(为了方便后续讨论,我们将这次的子进程称为 child1)。这里的作用有3个:首先,进程是从 shell 启动的,如果进程不结束,那么 shell 的命令行将 block 在这里,这一次 fork() 让 shell 认为父进程已经正常结束了。其次,child1 fork 出来的时候,默认加入了父进程的 progress group,这让 child1 不再是一个 group leader(它的 pgid 不等于 pid),这是调用 setsid 的必备条件。实际上,由于父进程退出,child1 所在的 process group 已经是一个 Orphaned Process Group。第三,由于父进程已经退出,所以 child1 的父进程是 init 进程。

setsid。由于 child 的 sid 依然是 shell 的 id,所以当 shell 退出的时候依然会被带走。所以这里要调用 setsid ,脱离 shell 所在的 session。但是 setsid 之后,它的 pgid 和 sid 都等于它的 pid 了,这意味着它成为了 session leader 和 group leader。这其实就是为什么要 fork 第二次的原因,也是我最大的困惑,和花了最多时间去理解的地方。

第二次 fork() 。为什么要第二次 fork() ?这个问题我读了很多不正确的 Stack Overflow 讨论,以及没有第二次 fork() 的实现,比如 Linux System Programming 5.7 Daemons 中的 daemon 代码就是没有第二次 fork() 的,Kernel 提供的 man 3 daemon 也没有第二次 fork。需要做第二次 fork() 的原因很简单:如果一个进程是 session leader,那么它依然有机会获得 tty 的(在基于 SysV 的系统下),我们要确保它不可能获得 tty,就再 fork() 一次,并且退出 child1,确保最终提供 daemon 服务的 child2 不是一个 session leader。

这个过程也可以看下  daemonize 里面的 daemon 函数,和上述过程一样。

我写了一段代码演示两次 fork() 各种 pid 的变化,得到的结果会和上图一样。

运行结果如下:

Protocol Mismatch

如果使用 systemd 这种任务控制机制的话,注意需要按照这些系统规定的 readiness protocol 来设定你的程序,即你要可以将 chdir,umask 这种事情托付给 systemd 来做,但是你要遵守 systemd 的协议来告诉它你的进程就绪,可以提供服务了。

常见的一种错误是在自己的进程中 fork() 了两次,但是在 systemd 中使用了 Type=simple ,并认为这样是告诉 systemd 自己的进程是一个普通进程,自己处理了 daemon。而实际上,这是在告诉 systemd 你的进程是启动后立马 ready,ExecStart 的进程就是目标进程,所以在第一次父进程 fork() 并退出的时候,systemd 认为你的进程挂了。 很多时候,比如用 systemd 控制 Redis Nginx 这种服务,总是启动超时,一般也是因为这个问题。 这里有很多常见错误的例子,就不一一解释了,介绍 systemd 的使用,又要写一篇文章了。

感谢依云和@mrluanma解答我百思不得其解的一些困惑。

参考资料

  1. What is the reason for performing a double fork when creating a daemon?
  2. What are “session leaders” in ps?
  3. Daemonize a process in shell?
  4. daemonize — A tool to run a command as a daemon 非常值得一读,代码只有几十行,对理解 daemonize 很有帮助。
  5. Linux System Programming P172
  6. Orphaned Process Groups
    1. APUE 9.10. Orphaned Process Groups
    2. GNU 28.4 Orphaned Process Groups
  7. TUE Linux Kernel
  8. Can systemd handle double-fork daemons?
  9. man 7 daemon 介绍了新式的 systemd daemon,和之前的 SysV 有什么不同。systemd 不会进行第二次 fork() ,所以你会发现用 systemd 管理的服务都是 session leader。这是因为这些服务不是从 shell 启动的,而是 systemd 启动的。
  10. daemon-skeleton-linux-c 另一个比较简单的 daemon 代码,可以直接编译运行
  11. Linux-UNIX-Programmierung – German
  12. Unix Daemon Server Programming
 

程序员如何高效和同行交流

你是否在工作中经常遇到这种对话:

A:你给我的接口为啥访问不通啊?

B:确定是 POST 方法吗?参数 foo 加了吗?

A:加了啊?

B:我看下。

5分钟后。

B:你没加 application/json 啊。

A:还是访问不通啊。

B:我看下。

5分钟后。

B:URL 后面不能加 / 啊,不需要加。

A:……

B:……

本文介绍一些如何避免这种无意义的对话的方法。个人认为,写在简历里面的“沟通能力强”并不是一个软技能,而是一个硬技能。不是说话啰嗦,敬词用的多就是沟通能力强,而是用最少的话把信息描述清楚没有歧义,这要求理智和健全的身心,以及相关的工具和背景知识。


糟糕的沟通:紧张的描述 “我的 xxx 怎么不 xxx?”  “xxx 了怎么办?” 别人很难回答你的问题,必须继续追问你几个回合之后才知道你想表达什么。

有效的沟通:以不让别人追问为原则,描述清楚自己遇到的问题,所处的运行环境,如何复现。能够让别人复现你的场景非常重要。举个例子,前端的问题可以用 jsfiddle 复现,后端问题可以提供一个 docker 命令。

另外,报告问题的时候,最好能简洁清晰的证明问题。比如,我们提供了一个 HTTP 接口,用户如果报告这个接口的问题,最简单直观的方法就是发一个 cURL 命令,阐明预期的返回是什么,但是现在的返回是什么。很多人在报告问题的时候用的是截图、他们自己代码的日志,问题是,我们无法知道你的代码怎么写的,无法判断这个问题是你的代码造成的还是我们的服务造成的。所以最好的方式,就是使用一个第三方的工具,清楚得表明做的事情和目前的现象。


糟糕的沟通:截图一大片代码,中间包含某个报错。并问:为啥我的命令的结果是这样?

有效的沟通:贴出来这个命令一个结果的文字版本,然后提问。

除非是 GUI 方面的问题,一般人都会憎恨从图片中抄写代码。贴 URL 、代码以及转发,要优于贴图片。

如果一些 IM 工具对代码的格式化不好,(比如微信和钉钉),可以将代码写到 Gist 然后贴 Gist 的链接。甚至传文件也比截图要强。

有时候你要将别的群聊内容转发给另一个对象的话,尽量使用 IM 的转发功能,这样别人就可以知道上下文,知道前因后果。如果公司特别大,你经常遇到去问一个人A,A甩手让你去问B,B又让你问C的话,转发功能就比较好用了。

无论是在网络论坛,还是聊天,亦或是邮件,都应该善用引用能,提供原文的地址要优于复述原文。

粘贴命令行的文本的时候(通常是日志),要带上生成这些文本的命令。因为光看到这些结果,别人不知道这些结果代表什么意思,是怎么来的。但是如果带上了 grep example.com access.log ,这样的命令,不必描述,别人就可以理解文本代表什么含义了。

如果实在要截图的话,也要尽量带上原始的命令,不要只截取结果。


糟糕的沟通:文档中写:接口参数是xxx,URL是xxx。

不要指望你这么描述能够让一个 HTTP 接口没有歧义。更糟糕的是,我见过 HTTP 的文档上就贴了一个 Java 的 Class,并且里面的注释类似这样: private String button; // 按钮

有效的沟通:在写文档或者 IM 工具中,尽量用 cURL 或 HAR 交流参数信息。如果是 HTTP API 文档,在有条件的情况下,可以搭建像 Swagger 这样的 API 平台。向接口的提供方描述问题的时候,不要说我使用的xxx怎么不行?可以直接贴给对方你的 cURL 命令,这样它们就知道你发送的请求了。现代的浏览器,以及 postman insomnia 这种 API 工具,都支持将 HTTP 请求导出到 cURL。

除了 HTTP,其他的交流也可以使用命令。比如遇到磁盘问题,可以将 df 或者 mount 命令之类的作为信息提供给运维同事。命令优于口头描述。


糟糕的沟通:在吗?

有效的沟通:直接提供你的问题,尽可能描述对方可能用到的所有的信息,不让别人追问。

这一条尤为重要,否则可能会被同事当做神经病。

 

Firefox Setup

今天做了今年最重要的一个决定,从用了将近 10 年的 Chrome 换到 Firefox 了。主要是因为1)Chrome 太慢了,尤其是对于我这种喜欢打开 N 多个 tab 的人 2)Firefox 注重隐私。

这篇文章记录一些配置项目。

设置

Firefox 自带的网络功能非常强大, 在 Chrome 需要第三方进程和插件才能实现的功能,用 Firefox 内置的配置就可以实现。

比如说可以不借助任何插件,直接将代理设置为 clash 的端口,将 DNS 设置为使用 CloudFlare 提供的 DNS over HTTPS。

换到 Firefox 之后感觉网速变慢了,刚开始以为是我 DoH 的问题,打开控制台看了了一下并不是。大部分的请求前面都有一段 block 的时间。这里另外需要改一个参数。

Firefox 默认每个 domain 使用的连接数是 6,如果请求数超过了可用连接数,那么这些请求就会被 block 住。

图中深红色的时间就是客户端在等到其他请求释放连接的时间。

修改的方式是在地址栏输入 about:config 打开高级模式。搜索 Network.http.max-persistent-connections-per-server ,将其改成 1024 。需要重启 Firefox 生效。

可以看到重启之后已经没有等待连接的时间了。

我使用的插件

之前的 Firefox 如果安装很多插件,会影响启动速度。可能是因为每个插件都要占用一个 Container 进程吧。所以除非必须,我不安装一些胡里花哨的东西。

  1. 1Password X:  使用体验竟然比 Chrome 好很多;
  2. Default Bookmark Folder: 用它来添加书签的时候可以搜索位置,适合我这种收藏狂魔 + 整理癖;
  3. Ghostery – Privacy Ad Blocker:不光能屏蔽广告,还能屏蔽 tracker;
  4. Tampermonkey: 这个同 Chrome 一样;

版本

目前有以下版本:

我常用的是 firefox-developer-edition,也装了 nightly 和稳定版。

使用体验

其他优点:

  • Firefox 在打开很多 tab 的情况下,如果放不开了,tab 就会变成可以滑动的;而 Chrome 直接不显示最新的 tab 了;
  • Firefox 的页面滚动比较平滑;UI也比较好,添加书签,刷新,后退等都有动画;
  • 开发者工具友好,信息比较直观,格式化的很漂亮。比如开发者工具的 network 栏,Chrome 跟其相比就相当于一个古老的 jQuery 实现的 table,Firefox 就像一个响应式的 UI 一样。如果直接输入一个返回 Json 的 URL,Firefox 默认会启用一个 Json 格式化的工具,比大部分的 Chrome json 插件和 Json 格式化网站都要强;
  • 内置的跟网络相关的工具很全。比如内置的代理,内置的 DNS over HTTPS;
  • 竟然内置了 Pocket,更方便了;
  • 开发者工具很多地方都带一个问号标志,点击一下可以直接跳到 MDN 的文档;
  • 进程树洁净。只有主进程和一些插件生存的 Container 进程。不像 Chrome 要那么多 Render 进程要占一屏的 htop;

缺点:

  • 中文字体的渲染在 OS X 下,相比于 Chrome 有些模糊(也有可能是我的错觉),2K 显示器下有点模糊,但是 Mac 的显示器还是非常清楚的;
  • 我最喜欢的 WordPress 编辑器很多快捷工作都不工作了,比如用 - 自动切换到列表模式, 改成代码样式等;
  • 一些网站的开发者用 Chrome,导致这些网站只在 Chrome 下工作,比如微信,在 Firefox 下聊天会遇到无法看到消息的 BUG;

 

就想到这些,以后朋友有用的 trick 再分享。

再见了,Chrome。


2019年12月08日更新:

网页版微信有巨大 BUG:聊天框会挂;网页版 微博在 firefox 登陆不上,微博也太烂了。

发现 Firefox 有 Container 功能,太实用了。测试自己网站的另外一个账号再也不用开隐身模式了,直接开 Container 就好了,相当于是 Cookie 完全不同的另一个浏览器,网站多账号轻松实现。