一个典型的 Shell 脚本的详细讲解和重构

上周无意中看到了一个 Shell 脚本,是一个很典型的开发工程师思维的脚本。我将会先分步讲解一下这个看上去很简单的脚本,随后再看如何重构。

脚本的完整代码如下:

PACKAGE="\"$1\""

line=$(java -version 2>&1  | grep $PACKAGE | grep -iv openjdk | wc -l)
#echo $line

if [[ $line =~ 0 ]]; then
  echo '{ "found": false , "not_found": true  }'

else
  echo '{ "found": true  , "not_found": false }'

fi

TL;DR 警告:如果太长不想看,就翻到最后看重构结果。

脚本的详细讲解

这段脚本的需求就是判断是否已经安装了特定版本的 Oracle JDK。在调用的时候会传入 Java 的版本号码,比如:1.8.0_161,这就是 $1 的值。

首先定义了 PACKAGE 这个变量,为什么在把 $1 赋值给 PACKAGE 的时候要转义双引号呢?是因为 java -version 这个命令输出的时候,版本号码是有双引号的。所以定义这个 PACKAGE 的作用是要精准匹配 java 的版本。

# Oracle JDK
[email protected] ~ $ java -version
java version "1.8.0_161"
Java(TM) SE Runtime Environment (build 1.8.0_161-b12)
Java HotSpot(TM) 64-Bit Server VM (build 25.161-b12, mixed mode)

# OpenJDK
[email protected] ~ $ java -version
openjdk version "1.8.0_144"
OpenJDK Runtime Environment (Zulu 8.23.0.3-macosx) (build 1.8.0_144-b01)
OpenJDK 64-Bit Server VM (Zulu 8.23.0.3-macosx) (build 25.144-b01, mixed mode)

然后就开始执行判断,并把结果赋值给变量 line。这行脚本看上去做了很多的事情,我们来拆开看一下。

java -version 用来输出版本号,但是输出到了标准错误上。而默认情况下,标准错误是无法通过管道传递给下一个命令的。所以需要 2>&1 来把标准错误的内容重定向到标准输出,这样才可以把 java 的版本号码传递给 grep 命令,否则 grep 是无论如何都无法获取到 java -version 的输出的。

第一个 grep 命令用来过滤 java -version 输出中包含版本号的所有行,并且这个版本号是用双引号包含起来的。正常情况下将会得到 java -version 命令输出的第一行。

然后再把结果传递给 grep -iv openjdk 命令。grep-i 选项是匹配时忽略大小写。-v 选项是排除,也就是反向匹配,将会输出不匹配的所有行。如果系统中正在使用的是 OpenJDK,前一个 grep 命令只是判断是否是指定版本的 JDK,包括 Oracle JDK 和 OpenJDK。这个 grep 命令将排除 OpenJDK 的输出,所以如果当前系统中使用的是 OpenJDK,这个命令执行之后可能会得到空的结果。

最后再用 wc -l 命令来得到结果的行数。如果是特定版本的 Oracle JDK,那结果就是一行。如果不是特定版本的 Oracle JDK,或者是 OpenJDK,就会输出空的结果,所以行数将会是 0

line 这个变量就会保存最后结果的行数,如果是 0 行显然就是系统中没有使用特定版本的 Oracle JDK。所以这里用了 [[ $line =~ 0 ]] 来判断变量 line 是否为 0

为什么最后会根据 line 是否为 0 来输出一个 JSON 的字符串呢?是因为这个脚本将在 Ansible Playbook 中调用。相关的 Ansible 代码如下:

- name: copy scripts to server
  copy: src="../files/check-java-version.sh"  dest="{{ java_download_path }}/"  mode="a+x"

- name: check if specific version of Oracle JDK is installed?
  shell: LC_ALL="en_US.UTF-8"  {{ java_download_path }}/check-java-version.sh  "{{ jdk_version }}"
  register: jdk_info
  changed_when: false
  failed_when: jdk_info.rc > 0

#- debug: var=jdk_info

- include: install.yml
  when: (jdk_info.stdout|from_json).not_found

这里有3个 task。第一个 task 是把脚本复制到服务器上,并设置了执行权限。第二个 task 是执行这个脚本,并且传入了 java 的版本,最后把 task 的执行结果保存到 Ansible 变量 jdk_info 中。jdk_infostdout 就将是 Shell 脚本最后输出的那个 JSON 字符串,比如:{ "found": false , "not_found": true }。第三个 task 是把第二个 task 的执行结果转换为 JSON 对象,并判断这个对象中的 not_found 的值是否为 true 来决定是否加载 install.yml 这个文件。

开始重构

所有相关的代码都分析完了,我们来看看如何重构,以及里面有哪些需要注意的问题。

之前也给几位朋友看过这个脚本,大部分人觉得问题主要在最后的 if 那里,尤其是输出 JSON 字符串的时候有互为相反的 foundnot_found 两个值。

先说典型问题,首先就是在 Bash 脚本中,如果不是特别需要,所有的变量一定要加上双引号!有没有双引号在 Bash 中是不一样的,不仅仅是双引号里面变量会被替换,而单引号不替换这个区别。

其次 if 的条件判断用了 =~ 这个操作符,这是 Bash 中的正则匹配的判断。判断语句 [[ $line =~ 0 ]] 的含义是判断变量 line 这个字符串里面是否包含字符 0。所以当 line 的值为 10, 201 都可以成功匹配的,这里显然不是期望这么判断的。当然了,正常情况下,line 的值要么是 1 要么是 0。正确的做法是,这里应该用 -eq 操作符来判断 line 这个变量是否等于数值 0

[[ "$line" -eq 0 ]]

最后是这个 if 判断有没有必要呢?

通常我们在 Java、Python、C 等语言里面函数的执行结果就是其返回值。在 Shell 脚本中,每一个命令的执行结果就是其输出。所以,对于刚从开发转到运维的工程师来说,就会习惯的想到根据命令的输出结果来判断。但 Shell 中还有一个很常用的判断方法就是根据命令的退出状态。如果命令执行成功,那退出状态就是 0,否则就是非 0。根据退出状态的不同,我们还可以知道命令为什么会执行失败。在 Bash 中,内部变量 $? 保存的就是前一个命令的退出状态。其实 Shell 脚本里的退出状态也可以类比为其他编程语言中的抛出异常。

Shell 脚本的退出状态就是脚本里面执行的最后一个命令的退出状态。通常我们不在意命令的输出,而只要根据退出状态来判断执行结果就可以了。对于现在重构的这个检查 Java 版本的脚本也一样,如果退出状态是 0 说明系统中是特定版本的 Oracle JDK。如果退出状态为非 0 说明不是特定版本的 Oracle JDK 或者安装的是 OpenJDK。

如果用退出状态来判断 Java 版本,那就没有必要输出 JSON 格式的结果了。if 的那5行代码就可以换成一行:

[[ "$line" -eq 1 ]]

如果是特定版本的 Oracle JDK,那变量 line 的值就是 1,这个判断就会成功,Shell 的退出状态就将是 0,否则是 1

可不可以再简单一点儿呢?是的,返回结果的行数也是没必要的。在获取行数那行里面

line=$(java -version 2>&1  | grep $PACKAGE | grep -iv openjdk | wc -l)

其实 java -version 2>&1 | grep $PACKAGE | grep -iv openjdk 就已经完成了特定 Oracle JDK 的判断。只有当前就是特定版本的 Oracle JDK 的时候,这个管道命令的退出状态才是 0,否则就是 1。那这个脚本就可以改写为 1 行:

java -version 2>&1  | grep "\"$1\"" | grep -iv openjdk

既然我们改用退出状态来判断结果了,那 Ansible 的 3 个 task 也要修改一下。由于脚本已经被改写为 1 行,其实也没必要放到一个脚本中专门执行了,所以就可以把第一个 task 删除掉。

第 2 个 task,原先是:

- name: check if specific version of Oracle JDK is installed?
  shell: LC_ALL="en_US.UTF-8"  {{ java_download_path }}/check-java-version.sh  "{{ jdk_version }}"
  register: jdk_info
  changed_when: false
  failed_when: jdk_info.rc > 0

最后有一行 failed_when: jdk_info.rc > 0,这行的含义就是如果命令执行失败就标记这个 task 为失败。类似于下面的代码:

if(jdk_info.rc == false)
  return false;
else
  return true;

所以其实这行本身就是没有用处的。但是现在我们要在 Ansible 中根据这个新的命令的退出状态来判断是否需要安装 Oracle JDK,所以需要忽略这个 task 的失败。因为 Ansible 在遇到任何 task 执行失败后都会终止执行剩余的 task。

改写后的 Ansible 代码如下:

- name: check if specific version of Oracle JDK is installed?
  shell: LC_ALL="en_US.UTF-8" java -version 2>&1 | grep  '"{{ jdk_version }}"' | grep -iv openjdk
  register: check_jdk
  changed_when: false
  ignore_errors: true

- include: install.yml
  when: ansible_check_mode or check_jdk is failed

最后用到了 Ansible 的内部变量 ansible_check_mode,用来判断是否运行在检查模式下。因为 Ansible 的 shell 模块不支持检查模式,所以当 Ansible 运行在检查模式下的时候,这个 task 会被忽略。导致 check_jdk 这个变量没有被定义,下一个 task 就会执行失败。

重构结果

删掉了一个脚本和一个 Ansible 的 task。

所以,原先脚本中的 java -version 2>&1 | grep $PACKAGE | grep -iv openjdk 这行就足够了,其它代码都不需要。Ansible 的 3 个相关 task 也可以删掉 1 个。

代码越多,Bug 也越多。