Bash 脚本往往从很小开始,并随着时间推移不断变大。你花五分钟写来复制几个文件的辅助脚本,最终可能会跑在 cron 定时任务、部署流水线或服务器启动脚本里。因此,从一开始就遵循一些最佳实践,远比事后返工重要。

本指南介绍一些实用的 Bash 最佳实践,可让脚本保持可预测、可安全重跑,也便于他人阅读。

以清晰的 Shebang 行开头

每个 Bash 脚本都应以正确的 shebang 行开头。对于 Bash 而言,可移植的选择是:

#!/usr/bin/env bash

使用 /usr/bin/env bash 可以让脚本通过 PATH 找到 Bash,这在 Bash 安装在 /bin 之外的系统上(例如使用 Homebrew 的 macOS)很重要。如果你依赖仅在特定版本中存在的特性,可以锁定具体版本,例如 /usr/bin/bash。

启用严格模式

默认情况下,Bash 会忽略许多错误。某条命令可能失败、某个变量可能未设置,而脚本仍会若无其事地继续运行。在脚本顶部启用严格模式会改变这种默认行为:

#!/usr/bin/env bash

set
 -euo pipefail

下面说明每个标志的作用:

  • -e — 任何命令返回非零状态时立即退出。
  • -u — 将未设置的变量视为错误并退出。
  • -o pipefail — 只要管道中的任意命令失败,整个管道就失败,而不仅仅是最后一条命令。

一个常见的额外设置是 IFS=$'\n\t',它可以防止按空格进行单词分割,并避免在遍历含空格的文件名时出现意外。

#!/usr/bin/env bash

set
 -euo pipefail

IFS
=
$'\n\t'

严格模式并非万能。它无法捕获逻辑错误,而且在有些脚本中,你需要通过 || true 局部地关闭某些允许失败的命令。其意义在于把默认行为从“静默继续”翻转为“立即失败”,这样问题会尽早暴露出来。

始终为变量加上引号

在 Bash 中,未加引号的变量会先被展开,然后再进行单词分割。如果变量包含空格、通配符,或为空,结果可能和你期望的完全不同。

file
=
"My Report.txt"


printf
 
'<%s>\n'
 
$file

由于 $file 没有加引号,Bash 会把两个参数传给 printf:My 和 Report.txt。加上引号即可修复,并将文件名作为一个整体值保留下来:

printf
 
'<%s>\n'
 
"
$file
"

同样的规则也适用于测试、循环和命令替换之中。拿不准时,就加引号。唯一常见地故意不加引号的情况,是你确实需要单词分割(例如把字符串拆成多个参数),但这种情况很少见。

使用 $() 而非反引号

反引号 `command` 和 $(command) 都能运行命令并替换其输出,但一旦脚本超出单行范围,反引号就会带来麻烦。

第一个问题是嵌套。反引号本身不能嵌套,所以每一层内部的反引号都需要转义:

echo
 
"`basename \`dirname /var/log/syslog\``"
log

如果忘了加反斜杠,Bash 会在错误的位置关闭第一对反引号,于是 basename 在没有参数的情况下运行,而 dirname /var/log/syslog 则作为普通文本残留下来。而 $(...) 形式无需任何转义即可嵌套:

echo
 
"
$(
basename 
"
$(
dirname /var/log/syslog
)
"
)
"
log

引号是第二个问题。在反引号内部,Bash 会在解析内层命令前先去掉一层反斜杠,因此 \\a 会以 \a 的形式传入,echo 也只会打印出 a。而在 $(...) 内部,反斜杠会原样传递,保持不变。

反引号能做到的,$(...) 全部都能做到,因此请默认使用 $(...)。同时也要给结果加上引号,因为命令替换和任何其他展开一样,也会进行单词分割。

优先使用 [[ ]] 而非 [ ]

Bash 支持两种测试语法:较旧的 [ ](即 test 命令的同义词)和较新的 [[ ]] 关键字。对于 Bash 脚本,请优先使用 [[ ]]:

if
 
[[
 -f 
"
$config
"
 
&&
 
"
$env
"
 
==
 
"production"
 
]]
;
 
then


    
echo
 
"Loading production config"


fi

[[ ]] 即使不加引号也能安全处理空变量,支持使用 == 进行模式匹配、使用 =~ 进行正则匹配,并允许在测试内部使用 && 和 || 这样的逻辑运算符。只有在纯 POSIX shell(如 dash)中才退回到 [ ]。

用函数封装重复逻辑

一旦脚本的步骤超过几个,就应当把相关的命令归组到函数中。函数能让脚本更易读、更易测试,也更易重构。

log
()
 
{


    
printf
 
'[%s] %s\n'
 
"
$(
date +
'%F %T'
)
"
 
"
$*
"


}




backup_dir
()
 
{


    
local
 
src
=
"
$1
"


    
local
 
dst
=
"
$2
"




    log 
"Backing up 
$src
 to 
$dst
"


    rsync -a 
"
$src
/"
 
"
$dst
/"


}




backup_dir 
"/var/www"
 
"/backup/www"

在函数内部使用 local 声明变量,避免它们泄漏到脚本的其他部分。把输入作为位置参数传入,并通过 echo 或 printf 返回输出,而不是使用全局变量,因为全局变量难以追踪。

关于参数、返回值与作用域的更多细节,请参阅 Bash 函数相关文档。

用数组构建命令参数

引号保护的是单个值,但脚本常常需要构建一整列参数并交给某个命令处理。把这一列参数塞进一个普通字符串是行不通的,因为字符串会被单词分割:

opts
=
"--exclude 'My Report.txt'"


printf
 
'<%s>\n'
 
$opts
<--exclude>
<'My>
<Report.txt'>

文件名被拆成了两段,而单引号也作为字面字符被带了进来。使用数组则可以让每个参数都保持完整:

opts
=(
--exclude 
"My Report.txt"
)


printf
 
'<%s>\n'
 
"
${
opts
[@]
}
"
<--exclude>
<My Report.txt>

使用 "${opts[@]}" 展开会为数组中的每个元素产生一个独立的 shell 词,无论其中含有什么内容。只要标志是条件性的,就该使用这种模式:

args
=(
-a --delete
)




if
 
[[
 
"
$verbose
"
 
==
 
"yes"
 
]]
;
 
then


    
args
+=(
--verbose
)


fi




rsync 
"
${
args
[@]
}
"
 
"
$src
/"
 
"
$dst
/"

引号和下标 @ 都要保留。"${opts[*]}" 会把所有元素合并成单个词,而未加引号的 ${opts[@]} 又会再次进行单词分割,让你回到原点。关于数组的更多细节,请参阅 Bash 数组文档。

显式处理错误

严格模式会在出错时停止脚本,但它不会告诉用户出了什么问题。在 ERR 上添加一个 trap,你就拥有了一个统一记录失败的地方。

#!/usr/bin/env bash

set
 -Eeuo pipefail



on_error
()
 
{


    
local
 
exit_code
=
$?


    
local
 
line
=
$1


    
echo
 
"Error on line 
$line
 (exit code 
$exit_code
)"
 >
&
2


    
exit
 
"
$exit_code
"


}




trap
 
'on_error $LINENO'
 ERR

-E 选项让 ERR 陷阱能够继承到函数和子 shell 中。如果没有它,辅助函数内部的失败可能会在退出脚本的同时,不运行你的错误处理程序。

对于清理临时文件之类的清理任务,请在 EXIT 上使用 trap:

tmp_dir
=
$(
mktemp -d
)


trap
 
'rm -rf "$tmp_dir"'
 EXIT

无论脚本是正常退出、在 set -e 下遇到错误,还是被 Ctrl+C 中断,EXIT 陷阱都会运行。这是保证清理的最安全方式。

尽早校验输入

接收参数的脚本,应当在参数为缺失或无效时快速失败。在执行任何实际工作之前先检查它们:

if
 
[[
 
$#
 -lt 
2
 
]]
;
 
then


    
echo
 
"Usage: 
$0
 <environment> <version>"
 >
&
2


    
exit
 
1


fi




env
=
"
$1
"


version
=
"
$2
"




case
 
"
$env
"
 in

    staging
|
production
)
 
;;


    *
)


        
echo
 
"Unknown environment: 
$env
"
 >
&
2


        
exit
 
1


        
;;


esac

对于带多个标志的脚本,请使用 getopts 而非手动解析 $@。它能以一致的方式处理短选项、选项参数和错误报告。

避免解析 ls 的输出

初学者常犯的一个错误是遍历 ls 的输出:

for
 f in 
$(
ls /var/log
)
;
 
do


    
echo
 
"
$f
"


done

这在文件名含有空格、换行符或特殊字符时会出问题。请改用 glob 或 find:

for
 f in /var/log/*
;
 
do


    
echo
 
"
$f
"


done

对于递归遍历,请使用 find 配合 -print0 与 xargs -0,或者使用 IFS= 和 -d '' 的 while read 循环,以正确处理异常文件名。

尽量保持脚本的幂等性

一个可以安全重跑的脚本,比一个第二次运行就出错的脚本要好用得多。应尽量做到幂等行为:在创建目录之前先检查,使用 mkdir -p 而非 mkdir,使用……

mkdir -p /opt/app/config

同样的模式也适用于软件包安装、用户创建和配置修改。像 Ansible 这类工具在设计上就强制保证了幂等性;而在 Bash 中,你需要自己加上这些检查。

使用 ShellCheck

ShellCheck 是面向 shell 脚本的静态分析器。它能在你把脚本投入生产环境之前,就捕获未加引号的变量、不安全的 for 循环等众多常见错误。

通过包管理器安装:

sudo apt install shellcheck

然后针对你的脚本运行它:

shellcheck script.sh

修复它报告的警告,或者用行内注释说明为何要忽略某条特定的检查。把 shellcheck 接入你的编辑器或 pre-commit 钩子,可以从根本上消除一整类 Bash 错误。

可读性优先于技巧性

Bash 充满各种快捷方式和晦涩的展开语法。它们能让脚本更短,但也会让六个月后的阅读变得更困难。即便要多写几个字符,也请选择更清晰的写法:

if
 
[[
 -z 
"
$name
"
 
]]
;
 
then


    
name
=
"anonymous"


fi

几乎总比精简压缩后的等价写法更易维护:

: 
"
${
name
:=anonymous
}
"

目标是写出同事读一遍就能理解的脚本,而不是需要查阅参考手册的脚本。

快速参考

关于可打印的快速参考,请参阅 Bash 速查表。

在提交 Bash 脚本之前,请确认以下几点:

  • 已存在 #!/usr/bin/env bash shebang 行。
  • 已启用 set -euo pipefail。
  • 所有变量展开都已加引号。
  • 命令替换使用 $(...) 而非反引号。
  • 测试使用 [[ ]] 而非 [ ]。
  • 函数替代了冗长的重复代码块,并使用 local 变量。
  • 参数列表以数组构建,并以 "${arr[@]}" 展开。
  • trap 负责处理错误与清理。
  • 工作开始前已校验输入。
  • 不解析 ls 的输出。
  • shellcheck 不报告警告,或警告已被显式忽略。

常见问题

set -e 单独使用就够了吗?不够。set -e 会在很多错误时停止,但它会漏掉管道中的失败,并且忽略未设置的变量。应将其与 -u 和 -o pipefail 组合,以覆盖这些情况。

严格模式会让现有脚本出问题吗?它往往会暴露本来就存在的缺陷,例如未设置的变量和被忽略的错误。修复方法是对变量加引号、使用像 ${VAR:-} 这样的默认值,以及……

我应该用 Bash 写脚本,还是改用 Python?对于主要调用其他命令的简短“胶水”脚本,Bash 是不错的选择。一旦脚本增长到几百行以上、涉及复杂的数据结构……

在哪里可以学习更多安全的脚本编写模式?在编写脚本时,请保持 ShellCheck 维基页面打开,并通读官方 Bash 手册中关于 shell 内建命令的部分。两者都解释了每条规则背后的原理。

总结

严格模式、变量加引号,再加上几处恰当的陷阱(trap),就能覆盖 Bash 脚本中的大部分风险。把这些实践作为你的默认习惯,在每次改动时都运行 shellcheck,你的脚本就会在本地、预发和生产环境中表现一致。