最近经历了人生的低谷。原因是在重构一些 Ansible 的部署脚本,Ansible 是本着声明式的理念世界的,但是这些脚本让我看的怀疑人生了。我开始思考这些脚本为什么会这么写。
脚本的作用非常简单,就是部署一些用 Go 写的程序。因为 Go 的程序依赖非常简单,只有一个 Binary,扔到机器上就能跑。
将问题简化一下,比如有三个服务,它们部署的过程分别如下。
服务A:
- 上传 binary;
- 上传配置文件;
- 上传 systemd 文件;
- 启动服务
服务B:
- 上传 binary;
- 上传配置文件;
- 新建一些目录;
- 上传 systemd 文件;
- 启动服务;
服务C:
- 上传 binary;
- 安装 Nginx;
- 上传 Nginx 配置文件;
- 启动 Nginx;
当前的部署脚本,总流程(Ansible 中的 Playbook)只有一个。这三个服务都将通过一个过程部署,传入一个 service_name
来区分要部署的是哪个服务。其简化过程如下:
- 上传 binary,binary 的名字就是
service_name
; - 上传配置文件,对应的
{{service_name}}.yaml
; - 通过变量检查是否要创建目录,如果是,就执行创建,否则跳过;
- 检查本地的配置文件中,对应
service_name
下面是否有xx_extra
,如果有,则需要上传; - 检查 systemd 文件,是否需要特殊处理,如果是,执行特殊处理;
- 上传 systemd 文件;
- 检查是否有安装额外软件,如果有(比如 Nginx),就安装;
- 额外的软件;
- 启动服务。
本来部署的代码是可以写成:
1 2 3 4 |
update my_api binary update my_api config update my_api.service file systemctl restart my_api |
现在却变成了:
1 2 3 4 |
common update binary common update config common update service file common restart service |
看起来,三个服务能够共用一个代码了,但实际上,在每一个 common
里面,都是一些:
1 2 3 4 |
if service_name == my_api: do this else if service_name == foo_service: do that |
披着一层 “复用” 的外衣,徒增了复杂度。
为什么要重构呢?因为这些脚本完全可以一个服务写一套,这样的复用没有任何意义。假如由于业务的需要,我们在安装服务C的时候需要特殊设置一个 log rotate 进程的话,需要这么做:
- 先在总的流程里面加一个
common config log rotate
; - 然后在这个
common config log rotate
里面添加一个判断:- 如果是在安装服务C,就执行对应的操作;
- 否则跳过;
这样的 if
越来越多,这套脚本就变成今天这样难以维护了。
为什么说这种复用是虚假的复用呢?Ansible 的逻辑里面,是可以支持复用的,但它的复用一般是将公共的部分封装,比如使用一个 role 来安装 Docker. 里面也有各种 if
来判断是什么 Linux 发行版,但是这种 if
是符合语义的:使用这个 role 可以帮你安装好 Docker,我在里面判断如果是 CentOS 就要这么安装,如果是 Ubuntu 就需要执行这些,等等。这是在“将复杂留给自己,将方便给用户”。当我使用他这个 role 的时候,我只要 include
进来,然后设置几个变量就好了。但是回到我们现在这个情况,此项目的复用没有带来任何简单。如果来了一个新的服务需要部署,我不可能直接使用原来的代码,必须要修改主流程,并且在原来的代码中添加 if
。
这几个服务本身就是在干不同的事情,完全不应该共用一套逻辑。虽然项目刚开始的时候看起来部署的流程差不多,但是看起来差不多并不意味着就有关系。随着业务发展,不同的项目必定或早或晚出现特殊的配置。
我感觉这种味道的代码非常常见,在业务的代码中经常见到不同的函数复用了一个公共的函数,这个公共函数里面又充满了各种 if
去判断调用者是谁,然后根据不同的调用者去执行不同的逻辑(等等,这不会就是 Java 常说的控制反转吧!)。怎么能知道什么是合理的复用,什么是不合理的呢?其实你看到就知道了,从代码中你能看到作者在“绞尽脑汁”想着怎么搞点抽象出来。
写这篇文章是今天在听《捕蛇者说 EP 29. 架构设计与 12fallacy(上)》的时候听大家谈到了应该警惕 code reuse, 表示深有同感。
那么,怎么能知道什么时候要 reuse,什么时候不要呢?我也不知道,但是感觉有几个思路是可以参考的。
- 多读文档,多学习。如果知道 Ansible 是声明式的写法的话,就不会使用这么多变量控制状态。使用任何一种工具,都需要阅读文档,理解这个工具的想法;
- 代码应该以人能读懂为首要目标。逻辑上没有关系的事情就不应该放在一起。人能读懂的代码应该可以经常被修改,能够经常被修改的代码必须足够简单。
另外,这一期嘉宾提到了德雷福斯模型,非常有趣:
德雷福斯模型(Dreyfus model of skill acquisition),将一个技能的学习程度类比成阶梯式的模型。由上而下分成:专家、精通者、胜任者、高级新手、新手五个等级。
各等级含意如下:
- 专家:凭直觉做事。
- 精通者:技能上:能认知自己的技能与他人差异,能透过观察别人去认知自己的错误,形成比新手更快的学习速度。职位上:能明确知道自己的职位在整体系统上的位置。
- 胜任者:能解决问题。
- 高级新手:不愿全盘思考。统计资料显示,多数人落在这个层级;当管理阶层分配工作给高级新手,他们认为每项工作一样重要,不明了优先层度,意味着他们无法认知每件工作的相关性。因此管理者认清,工作需给高级新手时,必须排列优先级。
- 新手:需要指令才能工作。
所以,努力让自己成为一个专家吧,拥有什么时候应该 Reuse 的“直觉”。
2021年07月09日更新一些内容:
Goodbye, Clean Code 描述了和本文很类似的一种“过度的”抽象。
The Wrong Abstraction 描述了一种在显示工作中更常见的一种情况:
- Programmer A sees duplication.
- Programmer A extracts duplication and gives it a name.This creates a new abstraction. It could be a new method, or perhaps even a new class.
- Programmer A replaces the duplication with the new abstraction.Ah, the code is perfect. Programmer A trots happily away.
- Time passes.
- A new requirement appears for which the current abstraction is almost perfect.
- Programmer B gets tasked to implement this requirement.
Programmer B feels honor-bound to retain the existing abstraction, but since isn’t exactly the same for every case, they alter the code to take a parameter, and then add logic to conditionally do the right thing based on the value of that parameter.
What was once a universal abstraction now behaves differently for different cases.
- Another new requirement arrives.
Programmer X.
Another additional parameter.
Another new conditional.
Loop until code becomes incomprehensible.You appear in the story about here, and your life takes a dramatic turn for the worse.
想起来我记忆很深的一篇文章,推荐下。
https://overreacted.io/goodbye-clean-code/
读完了,这篇文章写得太棒了,就是我想说的。用抽象换取一些代码的复用有些时候并不是一个好的 trade off!
#cool
就喜欢你这种读者 <3
又想起来一个,回来就这个问题再说说哈哈。
之前读云风翻译的《程序员修炼之道》第二版,没那么太在意,现在结合这篇文章想起来懂了一点点 DRY 不是抽象的基石,ETC 才是。https://blog.codingnow.com/2019/11/etc.html
而本文说的:
对与 Ansible 来说这种写法更 ETC。
如果进一步说的话,ETC 不光是为了现在的自己,还有未来的自己,和与能力相适应的团队。
我也是才懂一点点,希望未来变强一点的时候有更多感悟,再来回复。
Single Point Of Truth 很难得。实际的工程中由于接受困哪,或者沟通困难,Truth 到处都是,然后是各种各样的脚本将数据同步来同步去。
我自己写出来的这种同步服务就有两个了,比如 Prometheus 不支持我们的组件做服务发现,就只能写了个脚本同步数据到 zookeeper 里面去。
特别讨厌这种同步的服务,一旦出问题很难排查,也很难监控。因为本质上不是同步调用的了,而是异步触发的。
学习了