事故驱动开发

软件工程发展了这么多年,直到现如今,业界还是比较喜欢讨论 TDDBDDDDD

虽然最后那个和前两个不太像是同一个维度的东西,不过因为都是 xDD,我们就先放在一起了嗯。每一种理论都有不少拥趸和践行者,都希望在各自的不归路上一路走到黑。至于每一种理论是否能为大众所接受,那就不好说了。犹记得某届 gopherchina,七牛的 CEO 去讲 http 测试,然后被人喷讲的太渣,没有深度,可能这位同学的潜台词是:我们想要的是画满了框图的架构 ppt,就是想看各种方块啊线条啊什么的,你却给我们来讲怎么写 test 和这些看起来 low 的不行的 test。后该喷子被群起而攻之,大家纷纷表示测试是很重要的云云。不过你看这些围攻党吧,也没多少人敢夸耀一下自己工程的测试覆盖率。从测试的角度来讲,一般做业务的和做基础服务的观点就分道扬镳了。为什么大多数做业务开发的不愿意写测试?答案很简单的,因为我今天在写的是电商的订单系统,明天这个系统就变成公共厕所的收费系统了。当我开始打字的时候满心欢喜地一厢情愿地认为自己的代码可以活很久,只是一顿饭的功夫,所有的美好设想就烟消云散了。

至于 BDD 吧,其实就是换了一种形式的 TDD,假装是自己系统的用户,然后去做 e2e test,本质上和 TDD 没有什么区别。只是换了一种姿势,那你说 TDD 大家都不接受,BDD 就能接受?你当我三天一周就想做页面大改版的 PM 大大们是吃素的?所以到最后,所有的理想就变成了扔在垃圾堆里的“仪式感”了。你说仪式感又是忽悠谁呢,我们都是无神论者好么。哦,不不不,说不定看到这些花花绿绿的 test passed 的时候,我们确实从哪里冒出来了“哦,好棒”的错觉。

DDD 看起来倒是不错,但是理论这种东西,总是有历史局限性的。拿现在所谓的微服务场景来说,我们常说的就两个词:1 拆分;2 收敛。当我们不想做这件事情了我们就说要拆分,当我们觉得今年的 KPI 快要报销的时候就说不行不行,这事要收敛在我们这里。所以你看啊,我们拿完全相反的东西来同时作为我们做事情的金科玉律,这件事情就不可能有正确答案了。连要不要拆都没有共识,那什么领域建模,领域划分,都是扯蛋。

软件工程是软件的工程,更深的层面上是“人”的工程。人的工程总是免不了撕逼,技术界的撕逼比其它行业文明一些,我们讲求的是带案例的撕逼。

防御性编程?骗谁啊,没人捅我的时候,我哪知道我该穿的是防弹衣还是纸尿裤。最终能让大家达成共识而且人人遵守的业界规范,实际上就剩一条了,我把它总结一下,叫:事故驱动开发

为什么是共识呢,因为线上事故往往是和程序员们的 KPI 直接相关的,同时也是和程序员们的领导的 KPI 相关的,同时还是跟程序员的领导的领导们的乌纱帽相关的,再往上,是和公司的直接经济损失相关的。从人性的侧面来看,大多数人都是记打不记吃的。

你看啊,这次上下一条心了吧。

你可以观察一下,无论一个程序员、领导或者什么领导的领导再不靠谱,碰上事故的时候,都是惶恐无措,心惊胆颤,心有余悸,惴惴不安的。(如果你周围的同事连出事的时候都无所谓的话,那还是趁早开溜吧。

哪怕你们的系统不是特别重要,那出了事也是一定要解决的,解决以后是一定要复盘的。复盘以后也是一定要改进的。至于改进方案靠谱不靠谱是另外一回事了。

上面我也说了,咱做技术的是文明人,讲究从案例中出结论,就拿最近我们和其他人遇到的事情来说一说~

未协商好的日期时间格式修改

某业务的上下游在碰头之后决定将天级逻辑修改为小时级逻辑,但双方在上线时压根儿没有考虑历史存量数据。也就说,系统在线上跑了一段时间之后,线上的数据库里已经有了类似下面的数据:

act_id : 32
time : 2018-04-03

act_id : 1042
time : 2018-05-01

双方一拍即合,决定之后的时间格式修改为:yyyy-MM-dd HH,然后分别上线。所以你也差不多可以想到了,他们只是简单的把时间解析的代码从 time.Parse(str, "2006-01-02") 修改成了 time.Parse(str, "2006-01-02 15")。然后就觉得大功告成了。

上线一天后发现老的活动全部无法触发,成为了一场线上事故。

先不说这件事情反映出的程序员的问题,比如考虑不周,升级不想向前兼容什么的。这个问题本身是一个已解决问题。合法的日期时间格式是已知的问题空间,所以现在已经有开源库帮我们解决了这个问题:

https://github.com/araddon/dateparse

如果不是基于什么苛刻的性能考量,我们常见的所有日期格式根本都不需要手打任何解析字符串,就可以直接解析出来。当然了,我想你应该也不在乎这么一点日期时间解析的性能。本身大部分接口一次流程几十个 rpc 的时间够你挥霍的了。

结构体指针的 nil 判断

某天接到报警短信,一看是其它组的模块 panic 了。而且 panic 个不停,不胜其烦,遂上代码库翻阅其代码。好歹也是公司核心模块,但是其中的代码有类似这样的东西:

if order.a != nil && order.b != nil && order.c != nil && order.d != nil && order.e != nil && order.f != nil && order.g != nil && order.h != nil ... {
    save(order.a.x)
}

在冗长的 nil 判断之后,还是使用了一个没有前置 nil 判断的成员。

以前在微信群也曾经有某个 docker 大佬询问,怎么解决我们的模块上线各种 panic,而这些 panic 又都是特别弱智的 nil 判断的问题呢?

实际上这也是一个已解决的问题,只要用 validate 模块即可。

https://github.com/go-playground/validator

进业务逻辑之前,把 struct 上所有需要非空的成员都打上 required tag。这样进业务逻辑之后不需要做任何判断。这样把合法性检验全部拦截在入口层。后面就可以省心了。

不过还是忍住没有把这些东西给别人提,如果别人觉得不是问题,那贸然去提可能伤人自尊。。。。

等到出事故的时候,自然就会想着去解决了吧。

panic 时的锁无法退出

这次是我们自己的模块了。有同组的同事反映组内模块使用的 redis 组件的某个故障摘除子模块,在线上会发生 goroutine 爆涨。

从线上 dump goroutine 的现场,发现有死锁。死锁的原因?看看这段代码:

r.mu.Lock()
rand.Intn(param)
r.mu.Unlock()

聪明如你,大概一眼就看出来了吧。这里的 unlock 逻辑中间的 rand.Intn 是有可能发生 panic 的。而 panic 的时候自然会导致 unlock 逻辑被跳过。这样这个锁永远都不会被释放。自然其它 goroutine 会在这里等一辈子了。

在社区里也有很多糊涂虫,看到别人说锁的粒度大会影响性能,defer 会影响性能。然后就自作聪明地自己去控制 unlock 的位置。但往往又不考虑在加解锁中间可能会有类似这样 panic 的意外会导致你的锁永远都退出不了。而 panic 总是无所不在的。哪怕你现在加解锁中间的代码就是很简单,但你防不住项目迭代升级的时候别人往中间插进了可能 panic 的代码。所谓防御性编程大抵如此,不但要防自己,还要防别人。

和这个 case 比较类似的还有 panic 时跳过 waitgroup 的 done 操作,而使 waitgroup 永远无法退出。

结论:能 defer 的就尽量 defer,不要去听别人的胡言乱语。

最后

希望作为程序员的你能够脱离事故开发的怪圈。