命令行脚本实战(三):Bash Shell 语法与 Linux 自动化

写在前面

Shell 不是某一种固定语法,而是命令解释器这一类程序的统称。sh 可以指历史上的 Bourne Shell、POSIX 定义的可移植语法,或系统上名为 sh 的兼容实现。Bash 是 GNU 项目的 Shell,在 Linux 服务器和 CI 脚本中非常常见。

本文明确使用 Bash;如果脚本必须由 /bin/sh 运行,就不应使用 Bash 数组、[[ ... ]] 等扩展。

系列导航:第一篇:Windows Batch · 第二篇:PowerShell


一、Shebang 与执行方式

1
2
3
#!/usr/bin/env bash

echo "Hello, Bash!"
1
2
chmod +x script.sh
./script.sh

./script.sh 按 Shebang 选择解释器,bash script.sh 则明确交给 Bash。source script.sh 在当前 Shell 中执行,可以改变当前会话的变量和工作目录,不能与启动子进程混为一谈。


二、变量、环境变量与参数

1
2
3
4
5
name="demo"
port=8080
readonly app_env="production"

export LOG_LEVEL="info"

赋值等号两边不能有空格。普通变量只存在于当前 Shell,export 后才会传给子进程。

1
2
3
4
echo "script: $0"
echo "first:  ${1:-default}"
echo "count:  $#"
printf 'arg: <%s>\n' "$@"

"$@" 会保留每个参数的边界,是转发参数的标准写法。不加引号的 $@ 会再经历分词和文件名展开。


三、引号与 Shell 展开

1
2
3
4
5
name="Bash"
echo '$name'              # 原样字符
echo "Hello, $name"       # 变量展开
echo "kernel: $(uname -s)" # 命令替换
echo "$((2 + 3))"         # 算术展开

Bash 的展开包括大括号展开、波浪号展开、参数展开、命令替换、算术展开、单词分割和文件名展开等。这也是 Shell 很多陷阱的根源。

1
2
3
file="report final.md"
rm -- "$file"            # 正确:一个参数
# rm $file                # 错误:可能被拆成两个参数

一条实用规则是:变量展开默认加双引号,只在明确需要分词或 glob 时才取消。


四、参数展开

1
2
3
4
5
6
7
name="${1:-guest}"        # 空或未定义时用默认值
: "${API_KEY:?API_KEY is required}"

path="/var/log/app.log"
echo "${path##*/}"        # app.log
echo "${path%/*}"         # /var/log
echo "${#path}"           # 长度

${var:-default} 不会修改变量,${var:=default} 会赋值。${var:?message} 适合在脚本开始处校验必需配置。


五、条件与 case

1
2
3
4
5
6
7
if [[ -f "$file" ]]; then
    echo "regular file"
elif [[ -d "$file" ]]; then
    echo "directory"
else
    echo "missing"
fi

[[ ... ]] 是 Bash 条件语法,比传统 [ ... ] 更适合 Bash 脚本;需要 POSIX sh 兼容时使用 [ ... ]

1
2
3
4
5
case "${1:-}" in
    start|restart) echo "starting" ;;
    stop)          echo "stopping" ;;
    *)             echo "usage: $0 {start|stop|restart}" >&2; exit 2 ;;
esac

六、循环、数组与读取文件

1
2
3
4
5
6
7
8
for file in ./*.md; do
    [[ -e "$file" ]] || continue
    printf '%s\n' "$file"
done

for ((i = 0; i < 5; i++)); do
    echo "$i"
done
1
2
3
4
services=(api worker scheduler)
for service in "${services[@]}"; do
    echo "$service"
done

逐行读取时避免 for line in $(cat file)

1
2
3
while IFS= read -r line; do
    printf 'line: %s\n' "$line"
done < names.txt

IFS= 避免剪掉首尾空白,-r 避免把反斜杠解释为转义。


七、函数与返回状态

1
2
3
4
5
6
7
8
log() {
    printf '[%s] %s\n' "$(date '+%F %T')" "$*"
}

ensure_directory() {
    local dir=$1
    [[ -d "$dir" ]] || mkdir -p -- "$dir"
}

Shell 函数的 return 返回 0–255 状态码,不是任意数据。数据通常通过标准输出产生,调用者再用命令替换接收:

1
2
3
4
5
get_version() {
    printf '%s\n' '1.0.0'
}

version=$(get_version)

日志如果不属于函数的数据输出,应写入标准错误:printf 'message\n' >&2


八、管道、重定向与状态码

1
2
3
command >stdout.log 2>stderr.log
command >all.log 2>&1
printf '%s\n' a b c | sort | uniq

Unix 命令的约定是 0 表示成功,非 0 表示失败。

1
2
3
4
5
if curl -fsS https://example.com/health; then
    echo "healthy"
else
    echo "unhealthy" >&2
fi
1
2
build && deploy
cleanup || echo "cleanup failed" >&2

九、set -euo pipefail 不是魔法

1
set -Eeuo pipefail
  • -u:读取未定义变量时报错。
  • -o pipefail:管道中任一命令失败时,管道可返回非零状态。
  • -e:在诸多非零状态场景下退出,但存在重要例外。
  • -E:让 ERR trap 在函数、命令替换和子 Shell 中更容易继承。

set -eif 条件、while 条件、&& / || 列表、被 ! 取反的命令等上下文中不按“遇到非零立即退出”的简化规则工作。因此关键命令仍应显式地放在 if 中或检查其状态。


十、完整实战:构建并打包 Hugo 站点

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#!/usr/bin/env bash
set -Eeuo pipefail

readonly script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
readonly output_dir="${script_dir}/public"

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

on_error() {
    local exit_code=$?
    local line_no=$1
    printf '[ERROR] line %s, exit code %s\n' "$line_no" "$exit_code" >&2
    exit "$exit_code"
}
trap 'on_error "$LINENO"' ERR

command -v hugo >/dev/null 2>&1 || {
    echo '[ERROR] hugo was not found in PATH' >&2
    exit 10
}

cd -- "$script_dir"
log 'Building site'
hugo --minify --buildFuture --cleanDestinationDir -d "$output_dir"

log 'Creating archive'
tar -C "$output_dir" -czf "${script_dir}/site.tar.gz" .
log 'Done'

${BASH_SOURCE[0]} 指向当前 Bash 源文件,比 $0 更适合函数和被 source 的场景。cd ... && pwd 将脚本目录规范化为绝对路径。


十一、常见陷阱

  1. 不加引号的变量可能触发分词和 glob。
  2. 不要用 for x in $(cat file) 逐行读取文件。
  3. 不要用 ls 的输出驱动文件处理,直接使用 glob 或 find 的结构化能力。
  4. 不要把 set -e 当成完整错误处理。
  5. 临时文件使用 mktemp,并通过 trap 清理。
  6. 对任意文件名处理时,优先使用 NUL 分隔,例如 find ... -print0

十二、三种脚本怎么选

维度Windows BatchPowerShellBash
主要环境WindowsWindows、Linux、macOSLinux / Unix-like
管道数据文本.NET 对象字节流 / 文本
优势Windows 零依赖、兼容历史环境结构化数据、系统管理、跨平台Linux 工具链、CI/CD、远程运维
短板语法易错、不擅长结构化数据启动和语法相对更重引号与展开规则复杂
典型场景启动器、简单安装脚本Windows 管理、API / JSON 自动化服务器部署、命令编排

Batch 解决 Windows 零依赖启动,PowerShell 擅长对象和系统管理,Bash 则是 Linux 命令编排的粘合层。选择工具的关键不是“谁的语法更现代”,而是目标环境、数据形态和脚本复杂度。

系列回顾:第一篇:Windows Batch · 第二篇:PowerShell

参考资料