第十三章:Shell 脚本编程:将重复工作自动化

第十三章:Shell 脚本编程:将重复工作自动化

摘要: 在 AI 辅助编程的时代,我们还需要手写 Shell 脚本吗?答案是:不一定,但我们必须能 读懂 它。Shell 脚本是 Linux 世界中实现自动化的“通用语”,是连接各种工具、服务和 CI/CD 流水线的终极“胶水”。本章的核心目标并非让你成为一名 Shell 编程专家,而是让你具备一种“脚本鉴识力”——能够快速理解一个脚本的意图,识别其潜在风险,并安全地对其进行修改和优化,从而真正驾驭自动化的力量。


在本章中,我们将遵循一条“鉴赏家”之路:

  1. 首先,我们将校准观念,明确在 AI 时代学习 Shell 脚本的新定位
  2. 接着,我们将学习 解剖一个脚本 所需的最基础知识,包括如何让它“活起来”。
  3. 然后,我们将快速过览脚本的核心“词汇”与“语法”,目标是“能读懂”而非“能默写”。
  4. 之后,我们将学习编写“健壮”脚本的两大“法宝”——函数严格模式,这是区分业余与专业的关键。
  5. 最后,我们将进行一次完整的 AI 辅助实战,从生成一个脚本初稿开始,一步步对其进行分析、重构和加固,体验现代开发者的真实工作流。

13.1. 2025 年的 Shell 脚本观:从“手写”到“读懂与改造”

核心思想: 让我们面对现实,在 2025 年 9 月 18 日的今天,当需要一个脚本来完成特定任务时,我们的第一反应已经不再是打开一个空白文件,逐行敲出 #!/bin/bash

现代开发者的工作流通常是这样的:

  1. 定义目标: “我需要一个脚本,每天凌晨 3 点备份 PostgreSQL 数据库,上传到阿里云 OSS,并清理掉 30 天前的备份。”
  2. 获取初稿: 打开 ChatGPT、Copilot、Gemini 或任何你喜欢的 AI 助手,输入你的目标。或者,在 GitHub Gist、Stack Overflow 上搜索类似的现成脚本。
  3. 审查与改造: 在几秒钟内,你会得到一个看起来能用的脚本初稿。而我们真正的、不可替代的核心工作,从此刻才刚刚开始
脚本的现代生命周期
2025-09-18 14:56

AI, 给我一个自动备份网站的脚本。

A
AI

好的,这是您的脚本… (生成一段脚本代码)

(拿到代码后) 好,现在轮到我了。

第一步:快速验证。这个脚本里有没有 rm -rf 这种危险命令?它的删除逻辑 find ... -delete 是否有严格的路径限制,会不会误删到系统文件?

第二步:精准修改。AI 用的备份目录是 /mnt/backups,我的服务器是 /data/backups,需要修改。它备份的是整个网站目录,但我想排除掉 cache 子目录。

第三步:融入系统 (Integrate)。脚本看起来不错,现在我要把它加入到系统的 crontab 中,让它每天定时执行。我还得确保它的输出日志能被正确记录,以便在出错时收到通知。

这就是我们在本章要学习的核心技能。我们不再追求成为脚本的“发明家”,而是要成为一个能驾驭这些自动化工具的“工程师”。我们需要掌握的,不再是茴香豆的“茴”有几种写法,而是足以让我们完成上述三个步骤的、最实用、最核心的 Shell 语法和最佳实践。

本章将始终贯穿“读懂、验证、改造”这一核心思想,让你把有限的精力,投入到创造最大自动化价值的环节上。


13.2. 解剖脚本:Shebang、注释与执行权限

痛点背景: 你从网上下载了一个 deploy.sh 脚本。当你满怀信心地在终端输入 deploy.sh 并回车时,系统却冷冷地返回 command not found。你换了种方式,输入 ./deploy.sh,这次又得到了 Permission denied。这背后究竟发生了什么?


13.2.1. 脚本的“身份证”:Shebang (#!)

几乎所有规范的Shell脚本,第一行都是以 #! 开头的。这被称为 Shebang

它的唯一作用:告诉操作系统,当“执行”这个文件时,应该调用哪个程序来作为解释器。

  • #!/bin/bash: 表示“请使用 /bin/bash 这个程序来解释并运行我下面的代码。” 这是最常见的写法。
  • #!/usr/bin/env python3: 表示“请在系统的 PATH 环境变量里查找 python3 这个程序,并用它来解释运行。” 这在Python或Node.js脚本中很常见,写法更具可移植性。

简单来说,Shebang就是脚本的自我介绍,它规定了自己应该被如何“阅读”。

13.2.2. 脚本的“说明书”:注释 (#)

在Shebang之后,所有以 # 开头的行,都会被解释器忽略。它们是写给看的注释,而不是给机器执行的命令。

一个专业的脚本,必然包含清晰、有用的注释,来解释代码的关键部分、作者、日期或使用方法。这也是我们“鉴赏”一个脚本好坏的重要标准。

1
2
3
4
5
6
7
8
9
#!/bin/bash
# ------------------------------------
# Author: Prorise
# Date: 2025-09-18
# Description: This is a simple demo script.
# ------------------------------------

# Print a greeting message to the console.
echo "Hello, World!"

13.2.3. 赋予“生命”:执行权限

在Linux中,一个文件能否作为程序被“执行”,是由它的文件权限决定的,与它的文件名(如 .sh.exe)无关。

让我们亲手实践一下:

第一步:创建我们的第一个脚本

1
2
3
# 创建一个名为 hello.sh 的文件,并写入内容
echo '#!/bin/bash' > hello.sh
echo 'echo "Hello from my first script!"' >> hello.sh

第二步:检查初始权限

1
ls -l hello.sh

注意,权限位是 rw-r--r--,其中完全没有 x (execute)的身影。这意味着,它现在只是一个普通的文本文件。

第三步:尝试执行(失败)
我们用 ./ 的方式来告诉Shell“请执行当前目录下的这个文件”。

1
./hello.sh

系统拒绝了我们,因为这个文件没有被授予执行的“许可”。

第四步:添加执行权限
我们使用 chmod (change mode) 命令,为文件所有者 (u) 添加 (+) 执行权限 (x)。

1
chmod u+x hello.sh

现在再来检查权限:

1
ls -l hello.sh

看!权限位中出现了 x,它现在“活”过来了。

第五步:成功执行

1
./hello.sh

两种执行方式的核心区别

  • ./script.sh: 这是在执行一个程序。它要求文件必须有执行权限 (x),并且操作系统会查看文件头部的 Shebang (#!) 来决定用哪个解释器。
  • bash script.sh: 这是将脚本文件作为参数传递给 bash 程序。它不要求文件有执行权限,因为我们是明确地告诉 bash:“请帮我读取并运行这个文本文件的内容”。这种方式会忽略脚本文件里的 Shebang。

在规范的自动化流程中,我们总是使用 chmod +x./script.sh 的方式。

概念核心描述关键命令/语法
Shebang脚本文件的第一行,指定执行该脚本的解释器路径。#!/bin/bash
注释# 开头的行,用于解释代码,会被程序忽略。# This is a comment
执行权限文件能否作为程序运行的许可。由 x 权限位标识。chmod u+x <script_file>
执行方式(推荐) 将脚本作为可执行文件运行。./script.sh
将脚本作为参数传给解释器。bash script.sh

13.3. 核心语法速览:脚本的“词汇”与“语法”

13.3.1. 变量与引用

变量是脚本中用来存储和传递信息的基本单元。

  • 定义: VARIABLE_NAME="some value" (注意:等号两边不能有空格)
  • 使用: $VARIABLE_NAME${VARIABLE_NAME}
1
2
3
4
#!/bin/bash
GREETING="Hello"
TARGET="World"
echo "$GREETING, $TARGET!"

【实战陷阱】: $VAR vs "$VAR" 的天壤之别
这是初学者最容易犯的、也是最致命的错误之一。规则:请永远在你的变量两边加上双引号。

让我们看看不加引号的后果。创建一个包含空格的文件名:

1
touch "my test file.txt"

现在,我们写一个脚本来处理这个文件名。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#!/bin/bash
FILENAME="my test file.txt"

echo "--- Without quotes ---"
# 未加引号,bash会将"my test file.txt"拆分成四个独立的词
for word in $FILENAME
do
echo "Found word: $word"
done

echo "--- With quotes ---"
# 加了引号,bash会将其视为一个完整的实体
for word in "$FILENAME"
do
echo "Found word: $word"
done

看到了吗?不加引号导致了灾难性的后果。如果你后面的命令是 rm $FILENAME,它会尝试删除三个名为 my, test, file.txt 的文件,而不是你想要的那个!

黄金法则: 除非你百分之百确定一个变量的内容永远不会包含空格或特殊字符,否则请永远使用双引号 "$VAR" 将其包裹起来。


13.3.2. 输入与输出 (I/O)

脚本需要与外部世界交互,这主要通过两种方式:接收参数和捕获其他命令的输出。

接收外部参数
脚本可以像普通命令一样接收参数。在脚本内部,它们通过特殊的变量来访问:

  • $1: 第一个参数
  • $2: 第二个参数,以此类推
  • $0: 脚本本身的文件名
  • $#: 传入参数的总个数
  • $@: 所有参数的列表("$@" 是最常用的形式)

创建一个名为 show_params.sh 的脚本:

1
2
3
4
5
#!/bin/bash
echo "This script is called: $0"
echo "You provided $# arguments."
echo "The first argument is: $1"
echo "All arguments are: $@"

赋予它执行权限 chmod +x show_params.sh 后运行它:

1
./show_params.sh prorise "hello world" 123

捕获命令结果
这是Shell脚本的“魔力”所在,它允许你将一个命令的输出结果赋值给一个变量。

语法: VAR=$(command)

1
2
3
4
5
6
7
#!/bin/bash
CURRENT_DATE=$(date +%F)
LOG_FILENAME="app-${CURRENT_DATE}.log"
echo "Log file for today will be: $LOG_FILENAME"

UPTIME_INFO=$(uptime -p)
echo "System has been up for: $UPTIME_INFO"

13.3.3. 逻辑判断 (Conditionals)

if 语句让我们的脚本可以根据不同的条件执行不同的操作。

语法:

1
2
3
4
5
6
7
if [[ condition ]]; then
# do something if condition is true
elif [[ another_condition ]]; then
# do something else
else
# do something if all conditions are false
fi

注意: 我们推荐使用现代的 [[ ... ]] 双方括号语法,它比传统的 [ ... ] 单方括号更强大、更不容易出错。

常用判断符:

  • -f "$FILE": 如果文件存在。
  • -d "$DIR": 如果目录存在。
  • -n "$STRING": 如果字符串非空。
  • "$STR1" == "$STR2": 如果字符串相等。
  • "$STR1" != "$STR2": 如果字符串不相等。
  • "$INT" -eq "10": 如果整数相等 (-eq: equal)。
  • "$INT" -gt "10": 如果整数大于 (-gt : greater than)。

实战: 检查一个文件是否存在。

1
2
3
4
5
6
7
8
#!/bin/bash
FILENAME="my_file.txt"
if [[ -f "$FILENAME" ]]; then
echo "'$FILENAME' exists. Reading its content."
cat "$FILENAME"
else
echo "Warning: '$FILENAME' does not exist."
fi

13.3.4. 循环结构 (Loops)

for 循环: 用于遍历一个列表(如文件名、服务器列表等)。

1
2
3
4
5
6
7
8
9
10
#!/bin/bash
# 备份所有 .conf 文件
for FILE in /etc/*.conf
do
# 检查文件是否存在且可读
if [[ -f "$FILE" ]] && [[ -r "$FILE" ]]; then
echo "Copying $FILE to /backup/"
cp "$FILE" /backup/
fi
done

while 循环: 通常用于需要满足某个条件才能持续执行的场景,最经典的应用是逐行读取文件。

1
2
3
4
5
6
7
8
9
10
11
12
#!/bin/bash
# 创建一个示例文本文件
echo "server1.prod.com" > server_list.txt
echo "server2.prod.com" >> server_list.txt

# 逐行读取文件并处理
FILENAME="server_list.txt"
while read -r line
do
echo "Pinging server: $line"
ping -c 1 "$line"
done < "$FILENAME"

read -r 中的 -r 参数可以防止反斜杠 \ 被错误地转义,是一个好习惯。

语法/概念示例核心描述
变量NAME="Prorise" / echo "$NAME"定义和使用变量。永远用双引号包裹
位置参数$1, $2, $#, $@在脚本内部获取从命令行传入的参数。
命令替换NOW=$(date)(常用) 捕获一个命令的输出并存入变量。
条件判断if [[ -f "$FILE" ]]; then ... fi根据文件、字符串、数字等条件执行不同逻辑。
for 循环for i in 1 2 3; do ... done遍历一个静态的或动态生成的列表。
while 循环while read -r line; do ... done < file逐行读取文件进行处理。

13.4. 编写“健壮”脚本的艺术

痛点背景: 你在网上找到一段脚本,它在你的测试环境里运行得很好。你把它部署到生产环境,结果因为一个意料之外的空文件名或者一个命令的偶然失败,脚本没有报错退出,而是继续执行了后面的 rm 命令,删除了错误的目录,引发了生产事故。如何从一开始就避免这类问题?


13.4.1. 使用函数 (Functions):代码的模块化

当一个脚本的逻辑变得复杂时,将它拆分成多个功能独立的函数,是提升代码可读性和可维护性的第一步。

语法:

1
2
3
4
5
function_name() {
# 函数体内的代码
# 可以像脚本一样使用 $1, $2 等来接收函数参数
local some_var="this is a local variable" # 使用 local 关键字
}

核心优势:

  • 可读性: 将复杂的逻辑封装成一个有意义的函数名,如 check_disk_space(),让代码意图一目了然。
  • 复用性: 同一段逻辑可以在脚本的不同地方被多次调用。
  • 作用域: 在函数内部使用 local 关键字声明的变量,只在函数内部有效,避免了全局变量污染的风险。

重构示例:
让我们看一个没有函数的“面条式”代码:

1
2
3
4
5
6
7
8
#!/bin/bash
echo "Starting backup for /var/www/html..."
tar -czf /backup/www-$(date +%F).tar.gz /var/www/html
echo "Backup for /var/www/html complete."

echo "Starting backup for /home/user/data..."
tar -czf /backup/data-$(date +%F).tar.gz /home/user/data
echo "Backup for /home/user/data complete."

使用函数进行重构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#!/bin/bash
# 定义一个通用的备份函数
create_backup() {
local source_dir=$1
local backup_name=$2
local timestamp=$(date +%F)
local destination="/backup/${backup_name}-${timestamp}.tar.gz"

echo "Starting backup for '$source_dir'..."
tar -czf "$destination" "$source_dir"
echo "Backup for '$source_dir' complete. Saved to '$destination'."
}

# 调用函数
create_backup "/var/www/html" "www"
create_backup "/home/user/data" "data"

重构后的代码不仅更简洁,而且意图清晰,更容易扩展和维护。


13.4.2. 【2025 最佳实践】开启“严格模式”

如果你只能从本章带走一个知识点,请带走这个。在你的所有Shell脚本的开头(Shebang之下)加上这“三项之力”,是让你的脚本从“玩具”升级为“工程工具”的最关键一步。

1
set -euo pipefail

让我们逐一拆解这三个选项的含义:

  • set -e: (exit on error)
    效果: 脚本中的任何一条命令执行失败(即返回一个非0的退出码),整个脚本将立即退出
    重要性: 这可以防止“错误滚雪球”。如果没有 -e,即使 tar 备份命令失败了,脚本也可能继续执行后面删除旧备份的命令,造成数据丢失。

  • set -u: (unset variable is an error)
    效果: 当脚本尝试使用一个未被定义的变量时,它会立即报错并退出
    重要性: 这可以防止因变量名拼写错误等低级失误导致的严重后果。例如,你本想执行 rm -rf "$TARGET_DIR/backup",却不小心拼错了变量名,写成了 rm -rf "$TRAGET_DIR/backup"。如果没有 -u$TRAGET_DIR 是一个空字符串,命令会变成 rm -rf "/backup",后果不堪设想!

  • set -o pipefail:
    效果: 在一个管道命令中 (cmd1 | cmd2 | cmd3),只要有任何一个子命令失败,整个管道命令的最终退出码就为非0(即失败)。
    重要性: 默认情况下,管道的退出码只由最后一个命令决定。比如 cat non_existent_file | wc -lcat 会失败,但 wc 成功了,整个管道就成功了。-o pipefail 修正了这种不合理的行为,确保了管道的健壮性。
    请将 set -euo pipefail 作为你的肌肉记忆!
    对于所有新的、严肃的Shell脚本,都应该无条件地在脚本开头加上它。它就像是为你的自动化流程系上了安全带。

概念核心描述价值
函数 func() { ... }将代码块封装成可复用的模块。提升脚本的可读性复用性可维护性
local VARNAME在函数内部声明局部变量。避免全局变量污染,增强代码的健壮性。
set -euo pipefail(黄金实践) 开启脚本的“严格模式”。(安全基石) 确保脚本在遇到错误时能快速失败 (Fail-Fast),防止小错误引发大灾难。