Shell 脚本很重要
Shell 真是个怪兽。尽管它与软件工程的所有当前趋势(强类型、编译检查优先于运行时检查等等)相悖,但 Shell 脚本依然存在,并且仍然是每个开发人员生活中的重要组成部分。
关于 shell 脚本的奇怪之处在于,即使是良好实践的强烈倡导者,在谈到 shell 脚本时也会忘记他们所知道的一切。
版本控制?何必呢,代码都是一次性的。
代码质量?这只是个 shell 脚本,反正就是垃圾。
测试?不行。目前没有合适的工具。
错,错,错。Shell 脚本有其价值。所有为真实代码所做的工作都应该在非琐碎的 Shell 脚本中完成,即使是一次性脚本。这包括版本控制、代码审查、持续集成、静态代码分析和测试。
这里总结了编写 shell 脚本时可以做和应该做的所有事情。
注意:本文将使用 Bash 作为参考 shell。大部分内容可以转换到其他符合 POSIX 标准的 shell。
保持脚本的版本控制
将 Shell 脚本置于版本控制之下有多个优点:
- 它构成了一个库。Shell 脚本可能很难编写。如果你能找到某个地方某个难题的参考资料,你的同事在需要的时候会感谢你的。你应该尽快在某个地方建立一个“shell-scripts”仓库。
- 它们可以得到适当的审查。Shell 脚本很容易出错,而且可能造成很大的损失。Shell脚本的代码审查应该是强制性的,就像任何其他代码一样。
- 它们可以改进。我不会向你解释什么是版本控制。但是有了版本控制的 Shell 脚本,定期改进就变得很容易了。
从现在开始,请在运行所有 Shell 脚本之前对其进行版本控制。在生产环境中执行脚本之前,请优先安排人员审核。这不仅浪费同事的时间,还能为团队节省时间。
使用 ShellCheck 提高脚本质量
虽然您可以使用命令检查脚本的语法有效性bash -n
,但还有更多强大的工具。
ShellCheck是一款用于 Shell 脚本的静态代码分析工具。它真的是一款非常棒的工具,能够帮助你在使用过程中提升技能。所以,一定要使用它。你可以在你的机器上全局安装它,在持续集成中使用它,它甚至可以与大多数主流编辑器完美集成。使用 ShellCheck 真的没有任何缺点,它还能帮你避免自己犯错。
如果 Steam 在 2015 年就使用了 ShellCheck,那么这句话就永远不会出现在生产环境中:
rm -rf "$STEAMROOT/"*
此代码违反了ShellCheck 的SC2115 规则。
使用 Bash 非官方严格模式
非官方的严格模式源自 Aaron Maxwell 的文章《使用非官方的 Bash 严格模式(除非你喜欢调试)》。他建议在每个 Bash 脚本的开头添加以下几行:
#!/bin/bash
set -euo pipefail
IFS=$'\n\t'
-
set -e
如果任何命令返回非零状态码,则将退出脚本。为了防止该选项在命令返回非零状态码时触发(即使没有发生错误),有两种解决方案: -
使用
|| true
模式:
command_returning_non_zero || true
- 暂时禁用该选项:
set +e
command_returning_non_zero
set -e
set -u
将防止使用未定义的变量。对于未定义的位置参数($1
、$2
、...),可以使用参数扩展结构为其赋予默认值:
my_arg=${1:-"default"}
-
set -o pipefail
将强制管道在第一个非零状态代码时失败。 -
IFS=$'\n\t'
使迭代和拆分不那么令人意外,尤其是在循环的情况下。此变量的默认值通常是,IFS=$' \n\t'
但空格作为分隔符往往会产生令人困惑的结果。
阅读原文,了解更多详细信息以及使用严格模式时常见挑战的解决方案!
非官方的严格模式比我们之前见过的模式更具侵入性,可能难以应对,但从长远来看,这是值得的。花点时间尝试一下。
做一些清理工作!
当脚本被中断时,无论是由于用户操作还是发生了其他意外情况,大多数 Shell 脚本都不会清理它们造成的混乱。最糟糕的情况是,它们甚至可能无法重启那些不得不暂时禁用的服务。考虑到用trap
命令执行清理和错误捕获是多么容易,这实在令人遗憾。
再次,在“ “退出陷阱”如何让你的 Bash 脚本更加健壮和可靠”中,Aaron Maxwell 给出了一些很好的建议。
始终在 shell 脚本中添加以下内容:
cleanup() {
# ...
}
trap cleanup EXIT
该命令将在脚本退出后立即trap
执行函数。在此函数中,您可以删除临时文件、重启服务或执行任何与脚本相关的操作。cleanup
使用 shUnit2 测试你的脚本
shUnit2是一个用于 Shell 脚本的单元测试框架。它受 JUnit 启发。它包含在标准仓库中,因此您可以apt-get install shunit2
在基于 Ubuntu 的发行版上安装它。
shUnit2 包含一个可以source
在测试文件中运行的 Shell 脚本。使用它的方法有很多种。为了避免主脚本过于杂乱,我更喜欢将测试写在一个单独的文件中。这意味着我将拥有一个script.sh
文件和一个test_script.sh
文件。
下面是一个提供两个数字相加功能的脚本示例。
add.sh
必须具有以下结构:
add() {
local a=$1
local b=$2
echo $(( a + b ))
}
if [[ "${BASH_SOURCE[0]}" = "$0" ]]; then
# Main code of the script
add $1 $2
fi
测试[[ "${BASH_SOURCE[0]}" = "$0" ]]
只在直接执行脚本时才执行主代码,而不是source
d。
test_add.sh
将会像这样:
. ./add.sh
test_add() {
actual=$(add 5 8)
expected=13
assertEquals "$expected" "$actual"
}
. shunit2
首先,测试文件source
是主文件add.sh
(在 Bash 中,.
是 的别名source
)。它声明的函数随后可在测试脚本中使用。
实际的测试是一些以 开头的简单函数test
。最后,全局安装的dshunit2
函数source
发挥了它的魔力。
然后可以执行测试文件:
$ bash test_add.sh
test_add
Ran 1 test.
OK
shUnit2 的功能细节在其文档中有说明。
shUnit2 有一些替代方案,例如Bats或Roundup,但我还没有机会使用它们。不过它们的用法应该比较类似。本节的重点是,无论你最终选择哪种解决方案,测试 Shell 脚本都是可行的,而且应该这样做。
记录脚本正在执行的操作
过去,我犯了一个错误,就是不记录任何内容。我喜欢运行脚本,然后看着它神奇地运行,控制台里却没有任何难看的东西。但我错了,因为当某些东西没有按预期工作时,就不可能知道发生了什么。运行脚本不应该感觉像魔术一样,它应该有点冗长且易于理解。为此,请尽可能在脚本中记录日志。
为此,我通常在脚本中添加以下几行:
readonly LOG_FILE="/tmp/$(basename "$0").log"
info() { echo "[INFO] $*" | tee -a "$LOG_FILE" >&2 ; }
warning() { echo "[WARNING] $*" | tee -a "$LOG_FILE" >&2 ; }
error() { echo "[ERROR] $*" | tee -a "$LOG_FILE" >&2 ; }
fatal() { echo "[FATAL] $*" | tee -a "$LOG_FILE" >&2 ; exit 1 ; }
这个小型日志框架可以轻松追踪脚本执行过程中发生的一切。记录日志变得非常简单,只需输入 即可info "Executing this and that..."
。然后,您就可以轻松地grep
在日志文件中查找特定内容。您可以根据需要随意改进这些功能,例如添加日期、调用函数名称(使用$FUNCNAME
)等。
我不使用内置函数,logger
因为它需要特殊权限才能写入,/var/log
而且我不喜欢它的用法。写入日志文件/tmp
通常就足够了。cron
不过对于脚本来说,你可能需要研究一下logger
。
根据需要使用-v
或--verbose
调用的命令来提高日志记录的质量。
学习调试脚本
除了记录日志之外,调试 Shell 脚本最简单的方法是使用 运行它bash -x
。另一种方法是set -x
在脚本内部使用。此选项将使 Bash 在执行每个命令之前打印它们,并将变量替换为其实际值。与非官方的严格模式一起使用,此方法有助于查看脚本中发生的情况,同时降低破坏环境的风险。
值得一提的是,Bash 也有一些调试器,例如bashdb 。bashdb 的工作方式与 gdb 相同,可以用来添加断点、切换到分步执行、显示变量的值等。您可以观看视频“使用 BashDB 调试 Shell 脚本”来学习如何使用 bashdb :
记录你的脚本
任何 Shell 脚本都应该有一个--help
选项。这看起来不容易?其实很简单,这要归功于以下技巧:
#/ Usage: add <first number> <second number>
#/ Compute the sum of two numbers
usage() {
grep '^#/' "$0" | cut -c4-
exit 0
}
expr "$*" : ".*--help" > /dev/null && usage
该usage
函数将打印以注释开头的每一行#/
,不带此前缀。
该expr
命令将检查所有参数连接后的字符串是否包含--help
。如果包含,则调用usage
。
这绝对不是解析参数最干净的方法,但这种快速方法可确保您添加一个--help
标志。
为了良好的实践,这个 StackOverflow 帖子while
解释了如何使用//case
结构正确解析脚本的参数shift
。
要从您的评论生成 HTML 文档,您还可以检查shocco.sh,它启发了上述技巧。
随机建议
以下是我历经艰辛才总结出来的一些好习惯。我会在讲解每条建议时解释其背后的原理。
使用 Bash 编写脚本
默认使用 Bash,必要时可以使用 Sh。除非有充分的理由,否则尽量不要使用 Ksh、Zsh 或 Fish。这种选择不仅可以确保你的脚本几乎可以在任何地方运行,还能促进整个团队对脚本的理解。你不是为自己编写生产脚本。
使用 Bashism
如果你使用 Bash,不要只用一半。使用参数扩展。使用局部变量和只读变量。使用改进的条件表达式。
引用你的变量
即使你知道不需要加引号,也应该始终给变量加引号。唯一的例外是你明确希望进行扩展。更多关于分词的信息。
命名参数
毋庸置疑,明确命名参数($1
、$2
、...)可以使代码具有自文档性,并有助于提高可读性。Bash 的参数扩展非常适合为位置参数命名和分配默认值:
my_arg=${1:-default}
使用子 shell 来控制全局范围内的内容
一个例子胜过千言万语:
var=1
echo $var
(
echo $var
var=5
echo $var
)
echo $var
将打印:
1
1
5
1
我主要用在需要临时修改$IFS
(例如,遍历简单的 CSV 类文件)然后将其重置为原始值时。
使用模板
这个脚本模板总结了本文分享的所有代码片段。我相信它可以为任何类型的脚本提供良好的基础。
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
#/ Usage:
#/ Description:
#/ Examples:
#/ Options:
#/ --help: Display this help message
usage() { grep '^#/' "$0" | cut -c4- ; exit 0 ; }
expr "$*" : ".*--help" > /dev/null && usage
readonly LOG_FILE="/tmp/$(basename "$0").log"
info() { echo "[INFO] $*" | tee -a "$LOG_FILE" >&2 ; }
warning() { echo "[WARNING] $*" | tee -a "$LOG_FILE" >&2 ; }
error() { echo "[ERROR] $*" | tee -a "$LOG_FILE" >&2 ; }
fatal() { echo "[FATAL] $*" | tee -a "$LOG_FILE" >&2 ; exit 1 ; }
cleanup() {
# Remove temporary files
# Restart services
# ...
}
if [[ "${BASH_SOURCE[0]}" = "$0" ]]; then
trap cleanup EXIT
# Script goes here
# ...
fi
保持知情
Shell 脚本最近没什么动静。所以,读一些相关的资料来了解一下吧,这样更容易跟上潮流!以下是一些有趣的资源:
man bash
我敢打赌,你们几乎没人读过它,但它确实是一个很棒的信息来源!而且我保证,它读起来并不难。- Twitter 上的@UnixTooltip不断提供提示和技巧。
- StackOverflow 上获得最多赞的 Bash 问题
- StackOverflow 上的 Bash 参与文档有一些有趣的例子。
- ShellCheck 的 wiki是一个很好的资源,可以了解什么不该做以及为什么不该做。
- Hacker News和/r/programming偶尔会出现一些关于这个主题的文章。
我希望这篇文章能让您了解 Shell 脚本的潜力。工具已经准备好了,实践也已经掌握。现在,就看您自己如何让脚本成为您和您的团队工作中的乐趣了!
文章来源:https://dev.to/thiht/shell-scripts-matter