1. 从“手忙脚乱”到“一键搞定”:为什么你需要Shell脚本
如果你在Linux或macOS的终端里,曾经为了完成一个任务,把同一串命令敲了十遍;或者,你需要在Windows的PowerShell里,每天重复执行一系列固定的文件整理操作,那么你肯定有过这样的念头:有没有办法让电脑记住这些步骤,下次点一下就能自动完成?
这就是Shell脚本要解决的问题。简单来说,Shell脚本就是一个包含了多条Shell命令的文本文件。Shell,你可以把它理解为你和操作系统内核(真正干活的“大脑”)之间的“翻译官”和“传令兵”。你输入ls、cd、cp这些命令,Shell负责接收、解释,然后告诉内核去执行。而脚本,就是你把要给“传令兵”的一连串指令,事先写在一张“任务清单”上,下次直接把清单交给它,它就能按顺序逐一执行。
看看那些热搜词吧:npm、node、claude命令找不到,本质是环境路径问题,一个脚本就能帮你检查和设置;adb shell反复安装APK、重启到bootloader,是安卓开发和测试的日常,写成脚本能省下大量重复劳动;用shell重命名文件、做开机自启脚本,更是自动化运维和系统管理的家常便饭。甚至那些看起来复杂的青龙脚本、碧蓝航线脚本,其底层逻辑也是通过脚本模拟用户操作,实现自动化。脚本的本质,是把零散、重复的手工操作,固化、序列化,最终实现自动化。
很多人觉得脚本编程是运维或开发工程师的专利,其实不然。无论你是数据分析师需要定期处理日志,是设计师需要批量转换图片格式,还是普通用户想自动备份重要文档,Shell脚本都能成为你的得力助手。它入门门槛低,直接使用你已经在敲的命令;它威力巨大,几行代码就能完成繁琐工作。这篇文章,我就以一个十几年老鸟的视角,带你从零开始,亲手编写你的第一个Shell脚本,并理解其背后的每一个细节。我们不求面面俱到,但求你能亲手写出一个真正有用、能解决实际问题的脚本,并理解每一步“为什么”要这么做。
2. 编写你的第一个脚本:从“Hello World”到可执行文件
让我们暂时忘掉那些复杂的自动化场景,就从最经典的“Hello World”开始。这个过程看似简单,却包含了编写Shell脚本所有最核心的步骤和概念。
2.1 创建脚本文件与Shebang
首先,打开你的终端(Linux/macOS的Terminal,或Windows的Git Bash、WSL)。找一个你常用的目录,比如家目录下的scripts文件夹(如果没有,可以用mkdir ~/scripts创建一个)。
进入这个目录:cd ~/scripts。现在,我们来创建第一个脚本文件。在Shell的世界里,脚本文件通常以.sh作为扩展名,但这并不是强制要求,只是一个良好的约定,让人一眼就知道这是个Shell脚本。
使用nano、vim或者你喜欢的任何文本编辑器(比如gedit、code .)创建一个新文件:
nano hello_world.sh文件的第一行,必须是这样一个特殊的注释:
#!/bin/bash这一行被称为Shebang(或 Hashbang)。它的作用是告诉系统,当直接执行这个脚本文件时,应该使用哪个解释器来运行它。#!是固定开头,后面的/bin/bash指定了Bash Shell的路径。为什么是/bin/bash?因为在绝大多数Linux发行版和macOS上,Bash是默认的、功能最丰富的Shell。你也可以写#!/bin/sh,但sh通常是dash的符号链接,它是Bash的一个精简版,某些Bash特有的语法在sh下可能无法运行。为了兼容性和功能的完整性,我们通常直接指定bash。
注意:在Windows的PowerShell中编写脚本(.ps1文件),其Shebang可能是
#!/usr/bin/env pwsh,原理是类似的,都是指定解释器。但本文聚焦于Unix-like环境下的Bash Shell脚本,这是应用最广泛的场景。
2.2 编写脚本内容与赋予执行权限
在Shebang行之后,你就可以开始写命令了。我们的第一个命令是:
echo "Hello, Shell Script World!"echo命令的作用就是将后面的字符串输出到终端屏幕。现在,你的hello_world.sh文件内容应该是这样的:
#!/bin/bash echo "Hello, Shell Script World!"保存并退出编辑器(在nano中是Ctrl+X,然后按Y确认,再按回车)。
现在,尝试直接运行它:./hello_world.sh。你很可能会看到这样的错误信息:bash: ./hello_world.sh: Permission denied。这是因为在Unix-like系统中,一个新创建的文件默认只有读写权限,没有执行(x)权限。你必须显式地告诉系统:“这个文件是可以作为程序来执行的。”
这就需要用到chmod命令来修改文件权限:
chmod +x hello_world.shchmod是“change mode”的缩写,+x表示为文件增加(+)执行(x)权限。执行完这条命令后,再用ls -l hello_world.sh查看一下,你会看到文件权限变成了-rwxr-xr-x,开头的-代表是普通文件,后面三组rwx分别代表文件所有者、所属组和其他用户拥有读(r)、写(w)、执行(x)权限。现在,脚本已经具备了被执行的资格。
2.3 运行脚本的三种方式及其区别
现在再次运行:./hello_world.sh。这次,你应该能看到终端上输出了“Hello, Shell Script World!”这行字。恭喜你,你的第一个Shell脚本成功运行了!
这里./代表当前目录。系统默认只在PATH环境变量指定的目录里寻找可执行文件,当前目录通常不在PATH中(这是出于安全考虑),所以必须加上./来指明路径。
除了这种方式,还有两种常见的运行方式:
- 使用指定的解释器执行:
bash hello_world.sh。这种方式直接调用bash解释器,并把脚本文件名作为参数传给它。此时,脚本文件本身不需要有执行权限(x),因为bash命令本身是可执行的,它只是去读取并解释hello_world.sh这个文本文件的内容。Shebang行在这种情况下会被忽略,因为解释器已经由你在命令行显式指定了。 - 通过source或点命令执行:
source hello_world.sh或. hello_world.sh。这种方式的特点是,脚本会在当前Shell进程中执行,而不是新建一个子Shell。这意味着脚本中设置的变量、改变的目录(cd)等,在执行完后会影响到你当前的终端环境。而前两种方式(./script.sh和bash script.sh)都会启动一个子Shell来执行脚本,脚本内部的环境变更不会“污染”父Shell。通常,我们运行独立任务脚本用前两种,而要加载环境配置(比如.bashrc)时,才会用source。
理解这三种方式的区别,对于后续脚本调试和环境管理非常重要。一个常见的坑是:在脚本里用export设置了一个环境变量,然后用./script.sh运行,结果发现当前终端里这个变量不存在。这就是因为变量被设置在了子Shell中,子Shell结束,变量就消失了。如果想让它生效,就需要用source script.sh来运行。
3. 让脚本变得有用:变量、参数与条件判断
一个只会说“Hello World”的脚本显然没什么用。要让脚本能处理实际任务,我们必须让它能接收外部信息,并能根据情况做出不同反应。这就涉及到变量、传递参数和条件判断。
3.1 使用变量存储信息
变量就像脚本里的临时记事本,可以存放各种信息,比如文件名、路径、计数器等。在Bash中定义变量很简单:
#!/bin/bash # 定义一个变量,等号两边不能有空格! my_name="Shell Learner" backup_dir="/home/user/backups" file_count=10 # 使用变量时,需要在变量名前加上美元符号 $ echo "Hello, $my_name" echo "Backup directory is: $backup_dir" echo "We have $file_count files to process." # 也可以把命令的执行结果赋值给变量 current_time=$(date) # 方法一:推荐,更清晰 current_time=`date` # 方法二:反引号,旧式写法 echo "Current time is: $current_time" # 系统预定义的特殊变量 echo "This script is named: $0" # 脚本本身的文件名 echo "My process ID is: $$" # 当前脚本的进程ID这里有几个关键点:
- 赋值时等号两边不能有空格:
var=value是正确的,var = value会被解释为运行命令var并带两个参数=和value,从而报错。 - 引用变量要加
$:echo $my_name。 - 命令替换:
$(command)或`command`可以将命令的输出捕获并赋值给变量。$(...)是更现代、更推荐的方式,因为它可以嵌套,且引号处理更清晰。 - 变量名规范:通常使用小写字母和下划线,如
backup_dir。全大写的变量名通常用于环境变量或脚本内部常量。
3.2 接收命令行参数
脚本如何知道用户想处理哪个文件呢?通过命令行参数。Bash提供了一系列特殊变量来获取这些参数:
#!/bin/bash # 假设脚本名为 process_file.sh, 这样调用:./process_file.sh file1.txt file2.txt /output echo "Script name: $0" # 输出:./process_file.sh echo "First argument: $1" # 输出:file1.txt echo "Second argument: $2" # 输出:file2.txt echo "Third argument: $3" # 输出:/output echo "All arguments: $@" # 输出:file1.txt file2.txt /output (每个参数作为独立单词) echo "All arguments as one string: $*" # 输出:file1.txt file2.txt /output (所有参数合并成一个字符串) echo "Number of arguments: $#" # 输出:3$1,$2,$3... 分别对应命令行上的第一、第二、第三个参数。$0是脚本名本身。$#表示参数个数。$@和$*都代表所有参数,但在循环处理时,$@能正确处理带空格的参数(如"my file.txt"),而$*会将所有参数视为一个整体,所以在需要遍历参数时,总是使用"$@"(带双引号)。
一个实用的例子:备份指定文件到指定目录。
#!/bin/bash # backup.sh # 检查参数数量是否正确,如果不正确则提示用法并退出 if [ $# -ne 2 ]; then echo "Usage: $0 <source_file> <backup_directory>" exit 1 # 非0退出码通常表示错误 fi source_file="$1" backup_dir="$2" backup_file="${backup_dir}/$(date +%Y%m%d_%H%M%S)_$(basename "$source_file")" # 检查源文件是否存在 if [ ! -f "$source_file" ]; then echo "Error: Source file '$source_file' does not exist." exit 2 fi # 检查备份目录是否存在,不存在则创建 if [ ! -d "$backup_dir" ]; then mkdir -p "$backup_dir" echo "Created backup directory: $backup_dir" fi # 执行备份 cp "$source_file" "$backup_file" if [ $? -eq 0 ]; then echo "Backup successful: $backup_file" else echo "Backup failed!" exit 3 fi这个脚本展示了如何用$1、$2获取参数,并用$#检查参数个数。$?是一个特殊的变量,它保存了上一条命令的退出状态码。0通常表示成功,非0表示失败。cp命令成功后,$?就是0,所以我们用if [ $? -eq 0 ]来判断备份是否成功。
3.3 使用条件判断控制流程
上面的脚本中已经用到了if语句。Shell脚本中的条件判断主要依靠test命令或其等价形式[ ... ]、[[ ... ]]。
[ ... ]:是test命令的另一种写法,方括号内部两端必须有空格。功能较基础。[[ ... ]]:Bash的增强版测试命令,支持更强大的字符串匹配(如==、=~正则),且逻辑更清晰,是更推荐的方式。
常见的测试条件:
| 测试类型 | [ ]写法示例 | [[ ]]写法示例 | 说明 |
|---|---|---|---|
| 字符串比较 | [ "$str1" = "$str2" ] | [[ $str1 == $str2 ]] | 字符串相等 |
[ "$str1" != "$str2" ] | [[ $str1 != $str2 ]] | 字符串不等 | |
[ -z "$str" ] | [[ -z $str ]] | 字符串长度为0(空) | |
[ -n "$str" ] | [[ -n $str ]] | 字符串长度非0 | |
| 整数比较 | [ $a -eq $b ] | (( a == b )) | 等于(推荐用(( ))) |
[ $a -ne $b ] | (( a != b )) | 不等于 | |
[ $a -gt $b ] | (( a > b )) | 大于 | |
[ $a -lt $b ] | (( a < b )) | 小于 | |
| 文件测试 | [ -f "file" ] | [[ -f file ]] | 存在且是普通文件 |
[ -d "dir" ] | [[ -d dir ]] | 存在且是目录 | |
[ -e "path" ] | [[ -e path ]] | 文件/目录存在 | |
[ -r "file" ] | [[ -r file ]] | 文件可读 | |
[ -w "file" ] | [[ -w file ]] | 文件可写 | |
[ -x "file" ] | [[ -x file ]] | 文件可执行 | |
| 逻辑组合 | [ cond1 -a cond2 ] | [[ cond1 && cond2 ]] | 逻辑与 |
[ cond1 -o cond2 ] | [[ cond1 || cond2 ]] | 逻辑或 | |
[ ! cond1 ] | [[ ! cond1 ]] | 逻辑非 |
重要提示:在使用
[ ]时,变量引用务必加上双引号,如[ -f "$filename" ]。否则,如果$filename包含空格或为空,命令会解析错误。[[ ]]在这方面更安全,但养成加引号的习惯总是好的。
让我们用if-elif-else结构写一个更复杂的例子,根据文件类型做不同处理:
#!/bin/bash # file_handler.sh target="$1" if [[ -z "$target" ]]; then echo "Error: Please specify a file or directory." exit 1 fi if [[ -d "$target" ]]; then echo "'$target' is a directory." echo "Listing contents:" ls -la "$target" elif [[ -f "$target" ]]; then echo "'$target' is a regular file." echo "File size: $(du -h "$target" | cut -f1)" echo "Last modified: $(date -r "$target")" # 判断文件是否可执行 if [[ -x "$target" ]]; then echo "This file is executable." fi else echo "'$target' is neither a regular file nor a directory (maybe a device file, symlink, etc.)." fi这个脚本首先检查是否提供了参数(-z判断是否为空),然后依次判断目标是目录、普通文件还是其他类型,并执行相应的操作。elif是else if的缩写,用于连接多个条件分支。
4. 自动化核心:循环与函数
当我们需要对多个项目执行相同操作,或者把一段常用代码封装起来时,循环和函数就登场了。它们是实现复杂自动化的基石。
4.1 遍历与重复:for循环与while循环
for循环常用于遍历一个已知的列表,比如文件集合、参数列表等。
#!/bin/bash # 示例1:遍历固定字符串列表 echo "Processing fruits:" for fruit in apple banana cherry; do echo "I like $fruit" done # 示例2:遍历当前目录下所有 .txt 文件(这是处理通配符展开) echo "Processing text files:" for file in *.txt; do # 防止没有.txt文件时,循环会执行一次,file的值就是"*.txt" if [[ -f "$file" ]]; then echo "Found text file: $file" wc -l "$file" # 统计文件行数 fi done # 示例3:遍历所有命令行参数,这是处理参数最安全的方式 echo "Processing all arguments:" for arg in "$@"; do echo "Argument: $arg" done # 示例4:C语言风格的for循环,用于数字序列 for (( i=1; i<=5; i++ )); do echo "Count: $i" donewhile循环则用于当某个条件为真时,持续执行循环体。它常用来读取文件内容,或者等待某个事件发生。
#!/bin/bash # 示例1:计数器 count=1 while [[ $count -le 5 ]]; do echo "Count is $count" ((count++)) # 使用双括号进行算术运算,相当于 count=$((count+1)) done # 示例2:逐行读取文件(经典用法!) config_file="app.conf" if [[ -f "$config_file" ]]; then echo "Reading config file:" line_number=1 while IFS= read -r line; do # 跳过空行和以#开头的注释行 if [[ -n "$line" && "$line" != \#* ]]; then echo "Line $line_number: $line" fi ((line_number++)) done < "$config_file" # 输入重定向,将文件内容喂给while循环 fi # 示例3:从命令输出中读取 echo "Monitoring process list for 'bash' (Ctrl+C to stop):" while true; do # 无限循环 if pgrep bash > /dev/null; then echo "$(date): Bash process is running." else echo "$(date): No bash process found." fi sleep 2 # 等待2秒 donewhile IFS= read -r line是一个需要重点理解的模式:
IFS=将内部字段分隔符设为空,防止read命令去掉行首行尾的空白字符。-r选项防止反斜杠\被解释为转义字符。done < "$config_file"将文件内容重定向到while循环的标准输入,使得read命令能从文件中读取。
4.2 封装与复用:定义与使用函数
当一段代码需要被多次使用,或者为了提升脚本的可读性和结构性,我们就应该把它封装成函数。
#!/bin/bash # function_demo.sh # 定义一个无参数的函数:打印日志,带时间戳 log_message() { local timestamp=$(date '+%Y-%m-%d %H:%M:%S') echo "[$timestamp] $1" # $1 在这里是函数的第一个参数,不是脚本的参数 } # 定义一个有返回值的函数:检查URL是否可访问 check_url() { local url="$1" # 使用curl静默模式,只返回HTTP状态码 if curl --output /dev/null --silent --head --fail --max-time 5 "$url"; then return 0 # 成功,返回0 else return 1 # 失败,返回非0 fi } # 定义一个计算函数,利用命令替换返回值 calculate_sum() { local a=$1 local b=$2 local sum=$((a + b)) echo "$sum" # 通过echo输出结果,调用者用$(...)捕获 } # 使用函数 log_message "Script started." url_to_check="https://www.example.com" if check_url "$url_to_check"; then log_message "URL $url_to_check is accessible." else log_message "ERROR: URL $url_to_check is not accessible." fi result=$(calculate_sum 10 20) log_message "The sum of 10 and 20 is: $result" log_message "Script finished."函数的关键点:
- 定义:
function_name() { ... }或function function_name { ... }。前者是POSIX标准,更通用。 - 参数:函数内部使用
$1,$2...来获取传入的参数。注意:这和脚本主体的$1、$2是独立的。 - 局部变量:使用
local关键字在函数内声明变量,可以避免污染全局变量空间。这是一个非常好的习惯。 - 返回值:
- Shell函数通过
return语句返回一个退出状态码(0-255),0表示成功。这通常用于表示函数执行成败。 - 如果需要返回一个字符串或数值结果,常用的方法是在函数内使用
echo输出,然后在调用处用命令替换$(...)捕获,如上面的calculate_sum函数。
- Shell函数通过
- 调用:直接写函数名,参数跟在后面,用空格分隔。
函数让脚本变得模块化。你可以把常用的功能,比如日志记录、错误检查、发送通知等,都写成函数库(一个单独的.sh文件),然后在主脚本中用source functions_lib.sh来引入,大大提升代码的复用性和可维护性。
5. 实战:构建一个实用的日志文件清理脚本
现在,让我们把前面学到的所有知识——变量、参数、条件判断、循环、函数——组合起来,编写一个真正有用的脚本:一个自动清理旧日志文件的工具。这是系统管理中的常见需求。
5.1 需求分析与脚本设计
假设我们有一个应用程序,它每天都会在/var/log/myapp/目录下生成日志文件,命名格式为myapp-YYYY-MM-DD.log。我们希望写一个脚本,能够:
- 删除指定目录下,超过N天的日志文件。
- 可以指定要匹配的文件名模式(如
*.log或myapp-*.log)。 - 在真正删除前,最好能先预览一下哪些文件会被删除(“试运行”模式)。
- 脚本应该健壮,能处理目录不存在、权限不足等情况,并给出清晰的日志。
我们将设计脚本的调用方式如下:
./clean_old_logs.sh /var/log/myapp "myapp-*.log" 30参数1:要清理的目录。 参数2:文件名匹配模式。 参数3:保留最近多少天的文件。
加上一个可选的-n或--dry-run参数来启用试运行模式。
5.2 逐步实现脚本
首先,创建脚本文件clean_old_logs.sh,并赋予执行权限。
#!/bin/bash # clean_old_logs.sh - 清理指定目录下旧文件的实用脚本 # 定义颜色输出,让日志更易读(可选) RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' NC='\033[0m' # No Color # 日志函数 log_info() { echo -e "[INFO] $1" } log_warn() { echo -e "${YELLOW}[WARN]${NC} $1" } log_error() { echo -e "${RED}[ERROR]${NC} $1" } log_success() { echo -e "${GREEN}[SUCCESS]${NC} $1" } # 显示用法函数 show_usage() { echo "Usage: $0 [OPTIONS] <directory> <file_pattern> <days_to_keep>" echo "" echo "Options:" echo " -n, --dry-run Preview files to be deleted without actually deleting." echo " -h, --help Show this help message." echo "" echo "Example:" echo " $0 /var/log/myapp \"myapp-*.log\" 30 # Delete logs older than 30 days" echo " $0 -n /tmp \"*.tmp\" 7 # Dry-run for temp files older than 7 days" } # 初始化变量 DRY_RUN=false DIRECTORY="" PATTERN="" DAYS="" # 解析命令行参数(简单版本,更复杂的可以用getopts) while [[ $# -gt 0 ]]; do case $1 in -n|--dry-run) DRY_RUN=true shift # 移除已处理的选项 ;; -h|--help) show_usage exit 0 ;; -*) log_error "Unknown option: $1" show_usage exit 1 ;; *) # 第一个非选项参数是目录,第二个是模式,第三个是天数 if [[ -z "$DIRECTORY" ]]; then DIRECTORY="$1" elif [[ -z "$PATTERN" ]]; then PATTERN="$1" elif [[ -z "$DAYS" ]]; then DAYS="$1" else log_error "Too many arguments." show_usage exit 1 fi shift ;; esac done # 检查必需的参数是否提供 if [[ -z "$DIRECTORY" || -z "$PATTERN" || -z "$DAYS" ]]; then log_error "Missing required arguments." show_usage exit 1 fi # 验证天数是否为有效数字 if ! [[ "$DAYS" =~ ^[0-9]+$ ]]; then log_error "Days to keep must be a positive integer." exit 1 fi # 检查目录是否存在且可访问 if [[ ! -d "$DIRECTORY" ]]; then log_error "Directory '$DIRECTORY' does not exist or is not accessible." exit 1 fi if [[ ! -r "$DIRECTORY" ]]; then log_error "No read permission for directory '$DIRECTORY'." exit 1 fi log_info "Starting cleanup process..." log_info "Directory: $DIRECTORY" log_info "Pattern: $PATTERN" log_info "Days to keep: $DAYS" if [[ "$DRY_RUN" == true ]]; then log_warn "DRY-RUN MODE ENABLED. No files will be deleted." fi # 计算截止时间戳(当前时间减去DAYS天的秒数) CUTOFF_TIME=$(date -d "$DAYS days ago" +%s 2>/dev/null || date -v-${DAYS}d +%s 2>/dev/null) # 上面命令兼容Linux (date -d) 和 macOS (date -v)。如果都失败,则退出。 if [[ -z "$CUTOFF_TIME" ]]; then log_error "Failed to calculate cutoff date. Check your 'date' command." exit 1 fi DELETED_COUNT=0 ERROR_COUNT=0 # 进入目标目录,处理文件 cd "$DIRECTORY" || { log_error "Failed to cd to '$DIRECTORY'"; exit 1; } # 使用find命令查找匹配模式且修改时间早于截止时间的文件 # -name 指定模式,-mtime +$DAYS 表示修改时间在$DAYS天以前,-type f 只找普通文件 while IFS= read -r -d $'\0' file; do # 获取文件的修改时间戳 file_mtime=$(stat -c %Y "$file" 2>/dev/null || stat -f %m "$file" 2>/dev/null) if [[ -z "$file_mtime" ]]; then log_warn "Could not get modification time for '$file'. Skipping." ((ERROR_COUNT++)) continue fi if [[ $file_mtime -lt $CUTOFF_TIME ]]; then if [[ "$DRY_RUN" == true ]]; then log_info "[DRY-RUN] Would delete: $file (modified: $(date -d @$file_mtime))" else log_info "Deleting: $file" if rm -f "$file"; then ((DELETED_COUNT++)) else log_error "Failed to delete: $file" ((ERROR_COUNT++)) fi fi fi done < <(find . -maxdepth 1 -type f -name "$PATTERN" -print0) # 注意:这里使用了进程替换 <(command) 和 find 的 -print0,可以安全处理包含空格或换行符的文件名。 # 返回原目录(虽然不是必须,但是个好习惯) cd - > /dev/null # 输出总结 if [[ "$DRY_RUN" == true ]]; then log_success "Dry-run completed. Found $DELETED_COUNT file(s) that would be deleted. $ERROR_COUNT error(s)." else log_success "Cleanup completed. Successfully deleted $DELETED_COUNT file(s). $ERROR_COUNT error(s) encountered." fi exit $((ERROR_COUNT > 0 ? 1 : 0))5.3 脚本关键点解析与避坑指南
这个脚本虽然不长,但包含了很多Shell脚本编程的实用技巧和容易踩坑的地方:
- 参数解析:我们实现了一个简单的
while/case循环来解析-n、-h这样的选项。对于更复杂的选项(如带值的-d 30),建议使用Bash内置的getopts命令,它更标准、更强大。 - 输入验证:这是健壮脚本的生命线。我们检查了参数数量、目录是否存在、是否有读取权限、天数是否为数字。缺少这些检查,脚本在异常情况下会行为诡异或直接崩溃。
- 平台兼容性:
date命令计算过去时间,在Linux(GNUdate)和macOS(BSDdate)上的语法不同。我们用了||操作符尝试两种方式:date -d "$DAYS days ago" +%s(Linux)和date -v-${DAYS}d +%s(macOS)。如果都失败,脚本会报错退出。stat命令获取文件时间戳也存在类似的平台差异。 - 安全地处理文件名:这是最大的坑之一。如果文件名包含空格、换行符等特殊字符,简单的
for file in $(find ...)会出错。我们使用了find的-print0选项(用NULL字符分隔文件名)和while IFS= read -r -d $'\0'循环来读取,这是处理任意文件名的黄金标准。 - 进程替换:
< <(command)这个语法叫“进程替换”。它把command的输出作为一个临时文件描述符提供给while循环读取。这里用它来安全地传递find命令的结果。 - 清晰的日志与状态返回:脚本使用了不同颜色的日志级别(INFO, WARN, ERROR, SUCCESS),让运行结果一目了然。最后,脚本根据是否发生错误(
ERROR_COUNT)返回不同的退出码(0成功,非0失败),这便于其他脚本或工具调用时判断本次清理是否完全成功。 - “试运行”模式:通过一个
DRY_RUN标志,让脚本在不实际执行删除操作的情况下,列出所有将要被删除的文件。这是一个非常实用的功能,尤其是在执行破坏性操作前,让你有机会做最后确认。
你可以把这个脚本保存起来,根据你的实际需求修改(比如递归查找子目录可以去掉find的-maxdepth 1,或者添加邮件通知功能)。通过这个实战项目,你不仅学会了写脚本,更学会了如何思考、设计和实现一个可靠、实用的自动化工具。这才是Shell脚本编程真正的价值所在。