最近经历了人生的低谷。原因是在重构一些 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. 启动服务。

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

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 进程的话,需要这么做:

  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.