警惕复用的陷阱

最近经历了人生的低谷。原因是在重构一些 Ansible 的部署脚本,Ansible 是本着声明式的理念世界的,但是这些脚本让我看的怀疑人生了。我开始思考这些脚本为什么会这么写。

脚本的作用非常简单,就是部署一些用 Go 写的程序。因为 Go 的程序依赖非常简单,只有一个 Binary,扔到机器上就能跑。

将问题简化一下,比如有三个服务,它们部署的过程分别如下。

服务A:

  1. 上传 binary;
  2. 上传配置文件;
  3. 上传 systemd 文件;
  4. 启动服务

服务B:

  1. 上传 binary;
  2. 上传配置文件;
  3. 新建一些目录;
  4. 上传 systemd 文件;
  5. 启动服务;

服务C:

  1. 上传 binary;
  2. 安装 Nginx;
  3. 上传 Nginx 配置文件;
  4. 启动 Nginx;

当前的部署脚本,总流程(Ansible 中的 Playbook)只有一个。这三个服务都将通过一个过程部署,传入一个 service_name 来区分要部署的是哪个服务。其简化过程如下:

  1. 上传 binary,binary 的名字就是 service_name
  2. 上传配置文件,对应的 {{service_name}}.yaml
  3. 通过变量检查是否要创建目录,如果是,就执行创建,否则跳过;
  4. 检查本地的配置文件中,对应 service_name 下面是否有 xx_extra,如果有,则需要上传;
  5. 检查 systemd 文件,是否需要特殊处理,如果是,执行特殊处理;
  6. 上传 systemd 文件;
  7. 检查是否有安装额外软件,如果有(比如 Nginx),就安装;
  8. 额外的软件;
  9. 启动服务。

本来部署的代码是可以写成:

现在却变成了:

看起来,三个服务能够共用一个代码了,但实际上,在每一个 common 里面,都是一些:

披着一层 “复用” 的外衣,徒增了复杂度。

为什么要重构呢?因为这些脚本完全可以一个服务写一套,这样的复用没有任何意义。假如由于业务的需要,我们在安装服务C的时候需要特殊设置一个 log rotate 进程的话,需要这么做:

  1. 先在总的流程里面加一个 common config log rotate;
  2. 然后在这个 common config log rotate 里面添加一个判断:
    1. 如果是在安装服务C,就执行对应的操作;
    2. 否则跳过;

这样的 if 越来越多,这套脚本就变成今天这样难以维护了。

为什么说这种复用是虚假的复用呢?Ansible 的逻辑里面,是可以支持复用的,但它的复用一般是将公共的部分封装,比如使用一个 role 来安装 Docker. 里面也有各种 if 来判断是什么 Linux 发行版,但是这种 if 是符合语义的:使用这个 role 可以帮你安装好 Docker,我在里面判断如果是 CentOS 就要这么安装,如果是 Ubuntu 就需要执行这些,等等。这是在“将复杂留给自己,将方便给用户”。当我使用他这个 role 的时候,我只要 include 进来,然后设置几个变量就好了。但是回到我们现在这个情况,此项目的复用没有带来任何简单。如果来了一个新的服务需要部署,我不可能直接使用原来的代码,必须要修改主流程,并且在原来的代码中添加 if

这几个服务本身就是在干不同的事情,完全不应该共用一套逻辑。虽然项目刚开始的时候看起来部署的流程差不多,但是看起来差不多并不意味着就有关系。随着业务发展,不同的项目必定或早或晚出现特殊的配置。

我感觉这种味道的代码非常常见,在业务的代码中经常见到不同的函数复用了一个公共的函数,这个公共函数里面又充满了各种 if 去判断调用者是谁,然后根据不同的调用者去执行不同的逻辑(等等,这不会就是 Java 常说的控制反转吧!)。怎么能知道什么是合理的复用,什么是不合理的呢?其实你看到就知道了,从代码中你能看到作者在“绞尽脑汁”想着怎么搞点抽象出来。

写这篇文章是今天在听《捕蛇者说 EP 29. 架构设计与 12fallacy(上)》的时候听大家谈到了应该警惕 code reuse, 表示深有同感。

那么,怎么能知道什么时候要 reuse,什么时候不要呢?我也不知道,但是感觉有几个思路是可以参考的。

  1. 多读文档,多学习。如果知道 Ansible 是声明式的写法的话,就不会使用这么多变量控制状态。使用任何一种工具,都需要阅读文档,理解这个工具的想法;
  2. 代码应该以人能读懂为首要目标。逻辑上没有关系的事情就不应该放在一起。人能读懂的代码应该可以经常被修改,能够经常被修改的代码必须足够简单。

 

另外,这一期嘉宾提到了德雷福斯模型,非常有趣:

德雷福斯模型(Dreyfus model of skill acquisition),将一个技能的学习程度类比成阶梯式的模型。由上而下分成:专家、精通者、胜任者、高级新手、新手五个等级。

各等级含意如下:

  • 专家:凭直觉做事。
  • 精通者:技能上:能认知自己的技能与他人差异,能透过观察别人去认知自己的错误,形成比新手更快的学习速度。职位上:能明确知道自己的职位在整体系统上的位置。
  • 胜任者:能解决问题。
  • 高级新手:不愿全盘思考。统计资料显示,多数人落在这个层级;当管理阶层分配工作给高级新手,他们认为每项工作一样重要,不明了优先层度,意味着他们无法认知每件工作的相关性。因此管理者认清,工作需给高级新手时,必须排列优先级。
  • 新手:需要指令才能工作。

所以,努力让自己成为一个专家吧,拥有什么时候应该 Reuse 的“直觉”。


2021年07月09日更新一些内容:

Goodbye, Clean Code 描述了和本文很类似的一种“过度的”抽象。

The Wrong Abstraction 描述了一种在显示工作中更常见的一种情况:

  1. Programmer A sees duplication.
  2. 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.
  3. Programmer A replaces the duplication with the new abstraction.Ah, the code is perfect. Programmer A trots happily away.
  4. Time passes.
  5. A new requirement appears for which the current abstraction is almost perfect.
  6. 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.

  7. Another new requirement arrives.
    Programmer X.
    Another additional parameter.
    Another new conditional.
    Loop until code becomes incomprehensible.
  8. You appear in the story about here, and your life takes a dramatic turn for the worse.

警惕复用的陷阱”已经有6条评论

          • 又想起来一个,回来就这个问题再说说哈哈。

            之前读云风翻译的《程序员修炼之道》第二版,没那么太在意,现在结合这篇文章想起来懂了一点点 DRY 不是抽象的基石,ETC 才是。https://blog.codingnow.com/2019/11/etc.html

            而本文说的:

            多读文档,多学习。如果知道 Ansible 是声明式的写法的话,就不会使用这么多变量控制状态。使用任何一种工具,都需要阅读文档,理解这个工具的想法;

            对与 Ansible 来说这种写法更 ETC。

            如果进一步说的话,ETC 不光是为了现在的自己,还有未来的自己,和与能力相适应的团队。

            我也是才懂一点点,希望未来变强一点的时候有更多感悟,再来回复。

          • Single Point Of Truth 很难得。实际的工程中由于接受困哪,或者沟通困难,Truth 到处都是,然后是各种各样的脚本将数据同步来同步去。

            我自己写出来的这种同步服务就有两个了,比如 Prometheus 不支持我们的组件做服务发现,就只能写了个脚本同步数据到 zookeeper 里面去。

            特别讨厌这种同步的服务,一旦出问题很难排查,也很难监控。因为本质上不是同步调用的了,而是异步触发的。

Leave a comment

您的电子邮箱地址不会被公开。 必填项已用*标注