导言:从顺序执行到逻辑控制
第一章我们学会了写顺序执行的脚本,命令从上到下依次运行。但真实运维场景里,我们往往需要脚本「会判断、会重复、能复用」——比如满足条件才执行某段代码,重复执行某个操作上百次,把常用逻辑封装起来反复调用。
本章我们就来学习Shell脚本的核心逻辑能力:数值运算、条件判断、循环结构、函数封装以及重定向。这些是所有编程语言的通用核心逻辑,也是你从「会敲命令」到「会写脚本」的关键一步。掌握这些能力后,你将能够编写真正有自动化价值的脚本,而不仅仅是命令的堆砌。
2.1 数值运算与运算符:让Shell学会算术
在Bash中,变量默认被视为字符串,直接写 a=1+1 得到的是字符串 "1+1" 而不是 2。为了进行数学运算,我们需要借助特定的命令与运算符。
2.1.1 let 命令与 $((…)) 语法
对于常规的整数运算,最推荐使用 let 或 $((表达式))。
#!/bin/basha=10b=3
# 方式一:使用 let 命令let c=a+blet a++ # 变量自增echo "c的值是:$c"
# 方式二:使用 $((...)) (企业最常用,强烈推荐!)sum=$(( a + b ))mod=$(( a % b )) # 取余运算echo "sum=$sum, mod=$mod"2.1.2 expr 命令
expr 是一款老牌表达式计算工具。注意:它的运算符两边必须有空格,且乘号 * 需要转义。
#!/bin/bash# 注意空格和反斜杠转义result=$(expr 10 \* 2 + 5)echo "结果是:$result"2.1.3 bc 命令:解决小数(浮点数)运算痛点
前面两种方法仅支持整数。如果在运维中需要计算使用率(如 85.5%),必须使用 bc(Basic Calculator)计算器工具。
# scale=2 表示保留两位小数echo "scale=2; 10 / 3" | bc # 输出 3.33echo "1.5 + 2.3" | bc # 输出 3.8
# 在脚本中结合变量使用used=1045total=2048percent=$(echo "scale=2; $used / $total * 100" | bc)echo "磁盘使用率为:${percent}%"2.2 条件判断:if/else 分支与 test 测试表达式
Shell里的条件判断,本质是先执行一个「测试表达式」:表达式成立则返回状态码0(代表真),不成立则返回非0值(代表假)。if语句就是根据这个状态码,决定执行哪条分支。
这个设计理念贯穿整个Unix哲学——一切皆状态码,通过状态码的成功/失败来驱动脚本流程,这也是为什么Shell脚本在系统运维中如此高效。
2.2.1 条件测试基础:test 与 [ ]
最基础的测试命令是 test,它的等价简写是 [ 表达式 ],后者可读性更强,是行业通用写法。
WARNING新手第一高频坑:方括号两侧必须有空格!
- ❌ 错误写法:
[$a -gt 10]- ✅ 正确写法:
[ $a -gt 10 ]这是Shell语法的严格要求,缺少空格会导致语法错误。
测试表达式主要分三大类,其中文件测试是运维场景最高频的用法。
1. 文件测试
用来判断文件/目录是否存在、类型、权限等状态,是备份、监控类脚本的基础。
常用核心参数:
-e:判断文件/目录是否存在(最常用)-d:判断目标是否为目录-f:判断目标是否为普通文件-r/-w/-x:判断是否有读/写/执行权限-s:判断文件是否非空(大小大于0)-L:判断是否为符号链接
TIP运维场景建议:在脚本中执行文件操作前,总是先用
-f或-d判断文件/目录是否存在,避免因文件不存在导致脚本报错中断。
示例:
# 判断当前目录下的 test.sh 是否为普通文件[ -f test.sh ]# 用 $? 查看上一条命令的返回值,0代表成立,1代表不成立echo $?
# 判断目录是否存在且有写入权限(常见的目录检查)if [ -d /var/log ] && [ -w /var/log ]then echo "目录存在且有写入权限"fi实战应用:备份脚本中的文件检查
#!/bin/bashbackup_dir="/backup/data"
# 检查备份目录是否存在if [ ! -d "$backup_dir" ]then echo "备份目录不存在,正在创建..." mkdir -p "$backup_dir"fi
# 检查源文件是否存在且非空if [ -s /etc/nginx/nginx.conf ]then cp /etc/nginx/nginx.conf "$backup_dir/nginx.conf.$(date +%Y%m%d_%H%M%S)" echo "配置文件备份成功"else echo "源文件不存在或为空,跳过备份"fi2. 数值比较
用于两个整数的大小对比,注意Shell数值比较不能直接用>、<符号,必须使用专用参数。
常用核心参数:
-eq:等于(equal)-ne:不等于(not equal)-gt:大于(greater than)-lt:小于(less than)-ge:大于等于-le:小于等于
CAUTION重要提示:Shell中
>、<符号在数值比较中会被当作文件重定向操作符处理,不能用于数值比较!这是Shell新手最容易犯的错误。
示例:
# 判断 10 是否大于 5[ 10 -gt 5 ]echo $?
# 实战:磁盘空间监控脚本片段used_percent=$(df / | awk 'NR==2 {print $5}' | sed 's/%//')if [ $used_percent -gt 80 ]then echo "警告:磁盘使用率超过80%"elif [ $used_percent -gt 90 ]then echo "严重警告:磁盘使用率超过90%,请立即处理!"fi3. 字符串比较
用于判断字符串是否相等、是否为空。
常用核心参数:
=:两个字符串相等!=:两个字符串不相等-z:字符串长度为0(空字符串)-n:字符串长度不为0
NOTE严谨写法建议:字符串比较时,变量建议包裹双引号,避免空字符串引发语法报错。这在参数校验中特别重要。
示例:
[ "$name" = "张三" ]
# 判断字符串是否为空if [ -z "$variable" ]then echo "变量为空,请设置值"fi
# 判断字符串是否相等username=$1if [ "$username" != "root" ]then echo "只有root用户可以运行此脚本" exit 1fi
# 实战:参数校验最佳实践if [ -z "$1" ]then echo "用法:$0 <用户名>" exit 1fi
user_input="$1"if [ "$user_input" = "admin" ] || [ "$user_input" = "root" ]then echo "欢迎管理员登录"else echo "普通用户已登录"fi多条件组合
可以通过逻辑运算符拼接多个测试条件:
-a:逻辑与,两边同时成立才为真-o:逻辑或,任意一边成立即为真!:逻辑非,对结果取反
# 判断数字a大于5 且 小于20a=10[ $a -gt 5 -a $a -lt 20 ] && echo "成立"
# 判断文件存在且有执行权限[ -f script.sh -a -x script.sh ] && ./script.sh
# 判断用户不存在或用户ID为0(root)id=$3[ ! -n "$(id -u $username 2>/dev/null)" -o $id -eq 0 ] && echo "用户需要创建或已是root"IMPORTANT多条件组合提示:逻辑与(
-a)的优先级高于逻辑或(-o),复杂条件建议用括号明确优先级,或者拆分成多个if语句,提升代码可读性。
2.2.2 if 单分支结构
最基础的条件结构,满足条件就执行代码,不满足则直接跳过。
语法格式:
if [ 条件表达式 ]then 满足条件时执行的代码fi结尾是 fi(if 倒写),必须书写,代表if语句结束。
示例:判断文件是否存在,存在则打印提示
#!/bin/bashfilename="test.txt"
if [ -f "$filename" ]then echo "文件${filename}存在"fi实战应用:日志轮转脚本片段
#!/bin/bashlogfile="/var/log/app.log"
if [ -f "$logfile" ]then # 检查文件大小是否超过100MB size=$(stat -f%z "$logfile" 2>/dev/null || stat -c%s "$logfile" 2>/dev/null) if [ $size -gt 104857600 ] then mv "$logfile" "$logfile.$(date +%Y%m%d_%H%M%S)" touch "$logfile" echo "日志文件已轮转" fifi2.2.3 if-else 双分支结构
二选一结构:满足条件执行A逻辑,不满足执行B逻辑。
语法格式:
if [ 条件表达式 ]then 满足条件时执行的代码else 不满足条件时执行的代码fi示例:判断分数是否及格
#!/bin/bashread -p "请输入你的分数:" score
if [ $score -ge 60 ]then echo "恭喜,考试及格"else echo "很遗憾,考试不及格"fi实战应用:服务健康检查脚本
#!/bin/bashservice_name="nginx"port=80
if netstat -tln | grep -q ":$port "then echo "服务$service_name正常运行中" exit 0else echo "警告:服务$service_name未响应,正在重启..." systemctl restart $service_name sleep 2 if netstat -tln | grep -q ":$port " then echo "服务重启成功" else echo "服务重启失败,需要人工介入" exit 1 fifi2.2.4 if-elif-else 多分支结构
多条件依次判断,哪个先满足就执行对应分支,全部不满足则执行最终else。
语法格式:
if [ 条件1 ]then 条件1成立执行的代码elif [ 条件2 ]then 条件2成立执行的代码elif [ 条件3 ]then 条件3成立执行的代码else 所有条件都不成立执行的代码fi示例:根据分数输出评级
#!/bin/bashread -p "请输入你的分数:" score
if [ $score -ge 90 ]then echo "优秀"elif [ $score -ge 80 ]then echo "良好"elif [ $score -ge 60 ]then echo "及格"else echo "不及格"fiTIP条件范围设计原则:多分支从上到下依次匹配,必须保证条件范围从窄到宽。上面的例子中先判断
>=90再判断>=80,是正确的顺序。如果反过来先判断>=80,90分的情况会被提前捕获,后续的>=90分支永远不会被执行。
实战应用:系统负载监控告警脚本
#!/bin/bash# 获取1分钟内的平均负载load=$(uptime | awk '{print $(NF-2)}' | sed 's/,//')
# 获取CPU核心数cpu_cores=$(nproc)
# 计算相对负载(负载值/核心数)relative_load=$(echo "$load / $cpu_cores" | bc -l)
if [ $(echo "$relative_load > 2.0" | bc -l) -eq 1 ]then echo "严重告警:系统负载过高($load),相对负载:$relative_load" # 发送告警邮件或钉钉通知elif [ $(echo "$relative_load > 1.0" | bc -l) -eq 1 ]then echo "警告:系统负载较高($load),相对负载:$relative_load"elif [ $(echo "$relative_load > 0.5" | bc -l) -eq 1 ]then echo "系统负载正常($load),相对负载:$relative_load"else echo "系统负载低($load),系统空闲中"fi2.2.5 exit 命令提前终止脚本
配合条件判断,我们常使用内置命令 exit 来退出脚本并返回状态码。如前文的参数校验和健康检查示例中均有使用。
exit 0:代表脚本正常执行完毕并退出。exit 1(或其他非0值):代表脚本发生错误,异常退出。
if [ ! -f "config.yml" ]then echo "严重错误:配置文件不存在!" exit 1 # 立即终止脚本,不再往下执行fi2.3 多分支选择:case 语句与 select 交互菜单
当判断逻辑是「一个变量匹配多个固定值」时,多层if-elif会非常冗余,此时用 case 语句结构更清晰、可读性更强,特别适合菜单选择、参数匹配类场景。
2.3.1 case 语法格式
case 变量名 in 值1) 匹配值1时执行的代码 ;; 值2) 匹配值2时执行的代码 ;; 值3|值4) 匹配值3或值4都执行这段代码 ;; *) 全部不匹配时执行的默认代码 ;;esac语法说明:
- 结尾是
esac(case倒写),必须书写,代表case语句结束 - 每个分支结尾用
;;标记结束,匹配成功后执行对应代码,随即跳出case结构 |可连接多个值,代表「或」的关系,允许一个分支处理多个输入值*)是默认分支,匹配所有未命中的情况,相当于else,建议总是包含
NOTEcase 与 if-elif 的选择:
- 当条件是「一个变量匹配多个固定值」时用case(如菜单选择、参数检查)
- 当条件是「多个不同变量的复杂逻辑判断」时用if-elif(如多条件组合判断)
2.3.2 实战示例:简易系统管理菜单
#!/bin/bash# 系统管理工具菜单脚本
show_menu() { echo "===== 系统管理菜单 =====" echo "1. 查看当前日期" echo "2. 查看当前用户" echo "3. 查看磁盘使用率" echo "4. 查看内存使用率" echo "5. 查看进程列表" echo "6. 退出程序" echo "========================"}
while truedo show_menu read -p "请输入你的选择[1-6]:" opt
case $opt in 1) echo "当前日期是:$(date '+%Y年%m月%d日 %H:%M:%S')" ;; 2) echo "当前登录用户是:$USER" echo "用户ID:$(id -u)" ;; 3) echo "===== 磁盘使用情况 =====" df -h ;; 4) echo "===== 内存使用情况 =====" free -h ;; 5) echo "===== 进程Top10(按CPU使用率) =====" ps aux --sort=-%cpu | head -11 ;; 6) echo "感谢使用,欢迎下次使用" exit 0 ;; *) echo "输入错误,请输入1-6之间的数字" ;; esac
echo "" read -p "按Enter键继续..."doneIMPORTANT运维脚本最佳实践:
- 交互式菜单几乎都采用case实现,代码结构清爽
- 配合while循环实现菜单循环调用,用户可以连续执行多个操作
- 后续新增选项非常方便,只需在case中添加新的分支即可
更复杂的实战应用:服务管理脚本
#!/bin/bash# 服务启停脚本,支持多个服务
service_name=$1action=$2
case $action in start|运行) systemctl start $service_name echo "正在启动$service_name..." sleep 1 systemctl status $service_name ;; stop|停止) systemctl stop $service_name echo "正在停止$service_name..." sleep 1 ;; restart|重启) systemctl restart $service_name echo "正在重启$service_name..." sleep 2 systemctl status $service_name ;; status|状态) systemctl status $service_name ;; enable|开机启动) systemctl enable $service_name echo "$service_name已设置为开机启动" ;; *) echo "用法:$0 <服务名> <start|stop|restart|status|enable>" echo "示例:$0 nginx start" exit 1 ;;esac2.3.3 select 语句:自动生成交互菜单
select 语句能极其方便地生成带编号的交互式菜单,常常与 case 配合使用,是编写运维工具箱的另一神器。它省去了我们手写 echo 打印菜单和 read 读取输入的过程。
#!/bin/bash# 自定义提示符(默认是 #? )PS3="请输入要部署的环境编号: "
select env in "开发环境" "测试环境" "生产环境" "退出"do case $env in "开发环境") echo "正在部署开发环境..." ;; "测试环境") echo "正在部署测试环境..." ;; "生产环境") echo "危险操作!正在部署生产环境..." ;; "退出") echo "退出脚本" break # 跳出 select 循环 ;; *) echo "输入无效,请重新选择" ;; esacdone执行时,系统会自动在选项前加上 1) 2) 3) 供用户选择,极大地简化了代码。
2.4 循环结构:for / while / until 循环详解
循环的核心作用是让一段代码重复执行,是运维自动化的核心能力——批量创建用户、批量修改配置、遍历日志分析,都离不开循环。Shell里主流有三种循环:for、while、until。
2.4.1 for 循环
for循环是日常使用最多的循环,适合已知循环次数或遍历一组固定内容的场景。
写法一:遍历列表式(最常用)
语法:
for 变量名 in 值列表do 循环体代码done每次循环,变量会依次取值列表中的每一项,执行一次循环体代码。
示例1:遍历输出1到5
#!/bin/bashfor num in 1 2 3 4 5do echo "当前数字是:$num"done连续序列可以用大括号简写,比如 {1..10} 代表1到10,{a..z} 代表小写字母a到z:
for num in {1..10}do echo $numdone
# 遍历所有英文字母for letter in {a..z}do echo "字母:$letter"done示例2:批量创建10个系统用户
#!/bin/bash# 批量创建用户,并设置初始密码
for i in {1..10}do username="user$i" password="Init@2024_$i"
# 创建用户(如果已存在则跳过) if id "$username" &>/dev/null then echo "用户$username已存在,跳过" else useradd -m -s /bin/bash "$username" echo "$username:$password" | chpasswd echo "用户$username创建成功,初始密码已设置" fidone
echo "所有用户创建完成"示例3:遍历当前目录下所有.sh脚本文件,统计行数
#!/bin/bashtotal_lines=0
for file in *.shdo if [ -f "$file" ] then lines=$(wc -l < "$file") echo "文件:$file,行数:$lines" total_lines=$((total_lines + lines)) fidone
echo "所有脚本文件总行数:$total_lines"示例4:批量处理日志文件
#!/bin/bash# 处理/var/log下所有.log文件,压缩超过7天的日志
log_dir="/var/log"days=7
for logfile in $log_dir/*.logdo # 判断文件修改时间是否超过7天 if find "$logfile" -mtime +$days -print | grep -q "$logfile" then echo "正在压缩:$logfile" gzip "$logfile" fidone写法二:C语言风格for循环
适合精确控制循环次数,语法和C语言一致。
语法:
for (( 初始值; 循环条件; 变量变化 ))do 循环体代码done这种写法的优点是初始值、循环条件、变量递增都清晰直观,适合已知循环次数的场景。
示例:计算1到100的和
#!/bin/bashsum=0for (( i=1; i<=100; i++ ))do sum=$(( sum + i ))doneecho "1到100的和是:$sum"示例:模拟倒计时
#!/bin/bashecho "启动系统关闭,倒计时60秒..."
for (( countdown=60; countdown>0; countdown-- ))do echo "还剩${countdown}秒..." sleep 1done
echo "系统将关闭"# shutdown -h now示例:性能测试循环执行
#!/bin/bash# 重复执行某个命令100次,测试性能
for (( i=1; i<=100; i++ ))do curl -s "http://localhost:8080/api/test" > /dev/null if [ $? -eq 0 ] then echo "请求$i:成功" else echo "请求$i:失败" fidoneTIPfor循环选择建议:
- 遍历列表/文件时用第一种写法(
for var in list)- 精确控制循环次数时用第二种写法(
for (( i=1; i<=100; i++ )))
2.4.2 while 循环
while循环的规则是:条件为真就持续循环,条件变为假则停止,适合「不确定循环次数,靠状态终止」的场景。
语法:
while [ 条件表达式 ]do 循环体代码done示例1:计算1到100的和(while版本)
#!/bin/bashsum=0i=1
while [ $i -le 100 ]do sum=$(( sum + i )) i=$(( i + 1 ))doneecho "1到100的和是:$sum"示例2:逐行读取文件内容 while循环最经典的用法之一就是逐行读取文件,是日志分析脚本的基础:
#!/bin/bash# 逐行打印test.txt的内容
while read linedo echo "读取到一行:$line"done < test.txt示例3:监控进程是否运行,如果停止则重启
#!/bin/bash# 进程守护脚本
app_name="nginx"check_interval=10
while truedo # 检查进程是否运行 if ! pgrep -f "$app_name" > /dev/null then echo "$(date '+%Y-%m-%d %H:%M:%S') - $app_name进程已停止,正在重启..." systemctl start $app_name
# 重启后再检查一次 sleep 2 if pgrep -f "$app_name" > /dev/null then echo "$(date '+%Y-%m-%d %H:%M:%S') - $app_name重启成功" else echo "$(date '+%Y-%m-%d %H:%M:%S') - $app_name重启失败,告警!" fi fi
# 等待指定间隔后再检查 sleep $check_intervaldone示例4:实时监控文件变化(应用于日志监控)
#!/bin/bash# 实时显示新增的日志行(类似tail -f)
logfile="/var/log/app.log"last_size=0
while truedo current_size=$(stat -c%s "$logfile" 2>/dev/null || stat -f%z "$logfile" 2>/dev/null)
# 如果文件大小增加,显示新增内容 if [ $current_size -gt $last_size ] then tail -c $((current_size - last_size)) "$logfile" fi
# 如果文件大小减小(可能是日志轮转),重新开始 if [ $current_size -lt $last_size ] then echo "[日志文件已轮转]" tail -f "$logfile" break fi
last_size=$current_size sleep 1done2.4.3 until 循环
until循环和while正好相反:条件为假就持续循环,条件变为真则停止。它非常适合「等待某个状态达成」的场景,比如等待服务启动、等待网络连通。
语法:
until [ 条件表达式 ]do 循环体代码done示例:等待网络连通,连通后给出提示
#!/bin/bashecho "等待网络连通..."
until ping -c 1 baidu.com &> /dev/nulldo echo "网络还没通,3秒后重试..." sleep 3done
echo "网络已连通!"示例:等待服务端口开放
#!/bin/bash# 启动服务后等待端口开放,再执行后续操作
service_name="mysql"port=3306max_wait=30elapsed=0
echo "正在启动$service_name..."systemctl start $service_name
echo "等待$service_name启动完成..."
until netstat -tln 2>/dev/null | grep -q ":$port " || [ $elapsed -ge $max_wait ]do echo "等待中... ($elapsed/$max_wait秒)" sleep 2 elapsed=$((elapsed + 2))done
if netstat -tln 2>/dev/null | grep -q ":$port "then echo "$service_name已启动,端口$port已开放"else echo "等待超时,$service_name可能启动失败" exit 1fi示例:重试机制(API调用失败时重试)
#!/bin/bash# HTTP接口调用重试脚本
api_url="http://api.example.com/data"max_retries=5retry_count=0
until [ $retry_count -ge $max_retries ] || curl -sf "$api_url" > /dev/nulldo echo "API请求失败,第$((retry_count+1))次重试..." retry_count=$((retry_count + 1)) sleep 2done
if [ $retry_count -lt $max_retries ]then echo "API请求成功"else echo "API请求失败,已达到最大重试次数$max_retries" exit 1fiIMPORTANT循环类型选择速查表:
循环类型 适用场景 特点 for 已知循环次数或遍历列表 最高效,语义清晰 while 不确定循环次数,靠条件判断 灵活,常用于逐行读取 until 等待某个状态达成 与while逻辑相反,少用但在特定场景优雅
2.5 循环控制:break 与 continue 的区别与使用
循环执行过程中,有时需要提前终止循环,或者跳过当前这一轮循环,就需要用到两个控制命令:break 和 continue。
2.5.1 break:终止整个循环
执行break后,整个循环直接结束,后续所有轮次都不再执行。后续的代码会继续执行。
示例:循环输出1到10,数字到5时终止循环
#!/bin/bashfor i in {1..10}do if [ $i -eq 5 ] then break fi echo "当前数字:$i"doneecho "循环结束"运行后只会输出1-4,到5时直接跳出整个循环。
示例:用户认证失败后停止尝试
#!/bin/bash# 用户登录脚本,3次错误尝试后退出
max_attempts=3attempt=0
while truedo read -p "请输入密码:" password attempt=$((attempt + 1))
if [ "$password" = "correct_password" ] then echo "登录成功" break else echo "密码错误,第$attempt次尝试"
if [ $attempt -ge $max_attempts ] then echo "尝试次数过多,账户已锁定" break fi fidone2.5.2 continue:跳过本次循环
执行continue后,仅跳过当前这一轮循环的剩余代码,直接进入下一轮循环,循环整体不会终止。
示例:输出1到10,跳过所有偶数
#!/bin/bashfor i in {1..10}do # 判断是否为偶数 if [ $(( i % 2 )) -eq 0 ] then continue fi echo "奇数:$i"done运行后只会输出1、3、5、7、9,所有偶数都被跳过。
示例:批量处理文件,跳过隐藏文件和特殊文件
#!/bin/bash# 遍历目录,处理普通文件,跳过隐藏文件和目录
for file in *do # 跳过隐藏文件(以.开头) if [[ "$file" == .* ]] then continue fi
# 跳过目录,只处理普通文件 if [ ! -f "$file" ] then continue fi
# 处理普通文件 echo "处理文件:$file" # 你的处理逻辑...done示例:日志分析,跳过注释行和空行
#!/bin/bash# 分析配置文件,跳过注释行和空行
config_file="/etc/app.conf"
while read linedo # 跳过空行 if [ -z "$line" ] then continue fi
# 跳过注释行(以#开头) if [[ "$line" == "#"* ]] then continue fi
# 处理有效配置行 echo "配置:$line" # 解析和处理配置...done < "$config_file"NOTEbreak 和 continue 的一句话总结:
break= 「直接下班,全部结束」,终止整个循环continue= 「这次跳过,下次继续」,仅跳过当前一轮循环在实际使用中,continue出现频率远高于break,因为很多情况下我们需要跳过某些不符合条件的项,继续处理后续项。
2.6 函数封装:自定义函数、参数传递与返回值
当脚本中有一段代码需要反复使用时,每次复制粘贴既冗余又难维护。此时我们可以把这段代码封装成「函数」,起一个名字,后续使用时直接调用函数名即可。
函数的核心价值:代码复用、模块化拆分、提升脚本可读性与可维护性。好的函数设计可以把复杂的脚本拆分成多个功能单元,每个单元职责单一,逻辑清晰。
2.6.1 函数的定义与调用
定义语法
有两种等价写法,效果完全一致,带function关键字的写法可读性更强。
# 写法1:简写版函数名() { 函数体代码}
# 写法2:标准关键字版function 函数名 { 函数体代码}两种写法都被广泛使用,选择哪种主要取决于个人习惯和团队规范。
调用方式
直接书写函数名即可调用,不需要加括号:
函数名示例:封装一个打印欢迎语的函数
#!/bin/bash
# 定义函数say_hello() { echo "======================" echo " 欢迎使用运维脚本工具" echo "======================"}
# 调用函数say_helloWARNING重要提示:函数必须「先定义,后调用」,不能把调用语句写在定义语句前面。Shell脚本是顺序执行的,执行到函数调用时必须已经读取过函数的定义。
示例:函数调用顺序错误示例
#!/bin/bash
# 错误示例:先调用后定义,会报错say_hello # 报错:command not found
say_hello() { echo "你好"}2.6.2 函数的参数传递
函数也可以像脚本一样接收外部参数,用法和脚本的位置参数完全一致:
$1:第一个参数$2:第二个参数$#:传入参数的总个数$@:所有参数的完整列表$0:函数名(在函数内,$0仍然是脚本名,不是函数名)
示例:封装加法函数,接收两个数字参数并输出结果
#!/bin/bash
add() { sum=$(( $1 + $2 )) echo "$1 + $2 = $sum"}
# 调用函数,传入两个参数add 10 20add 30 40示例:接收可变数量的参数
#!/bin/bash
# 打印所有传入的参数print_all_args() { echo "参数总数:$#" echo "所有参数:$@"
for arg in "$@" do echo "- $arg" done}
# 调用print_all_args apple banana orange示例:参数校验最佳实践
#!/bin/bash
# 创建用户函数,需要接收用户名和初始密码
create_user() { # 参数校验 if [ $# -lt 2 ] then echo "错误:参数不足。用法:create_user <用户名> <密码>" return 1 fi
local username=$1 local password=$2 local shell=${3:-/bin/bash} # 第三个参数可选,默认为/bin/bash
# 检查用户是否已存在 if id "$username" &>/dev/null then echo "用户$username已存在,跳过创建" return 1 fi
# 创建用户 useradd -m -s "$shell" "$username" echo "$username:$password" | chpasswd echo "用户$username创建成功" return 0}
# 调用函数create_user "testuser" "Pass@2024"create_user "devuser" "Dev@2024" "/bin/zsh"TIPlocal关键字最佳实践: 函数内定义的变量默认是全局变量,会污染脚本的命名空间。建议在函数内部使用
local关键字声明局部变量,避免与脚本其他部分的变量冲突。示例:
Terminal window my_function() {local local_var="局部变量" # 仅在函数内有效global_var="全局变量" # 在函数外也可以访问}
2.6.3 函数的返回值
Shell里函数返回结果有两种方式,适用场景不同,新手极易混淆,需要明确区分。
方式1:return 返回状态码(官方标准)
用 return 语句返回,只能返回 0-255之间的整数,一般用来表示函数执行状态(0代表成功,非0代表失败)。调用后通过 $? 获取返回值。
这是Unix哲学的标准做法,所有命令都遵循这个约定。
示例:判断一个数字是否为正数
#!/bin/bash
is_positive() { if [ $1 -gt 0 ] then return 0 # 是正数,返回成功状态 else return 1 # 非正数,返回失败状态 fi}
# 调用函数is_positive 5if [ $? -eq 0 ]then echo "是正数"else echo "不是正数"fi示例:检查服务是否运行
#!/bin/bash
check_service_running() { local service_name=$1
if systemctl is-active --quiet $service_name then return 0 # 服务运行中 else return 1 # 服务未运行 fi}
# 调用if check_service_running "nginx"then echo "nginx服务正常运行"else echo "nginx服务未运行,正在启动..." systemctl start nginxfi方式2:echo 返回数据(企业实战最常用)
由于return只能返回0-255的整数,无法返回字符串、大数字等复杂数据,因此实战中更通用的方式是:函数内部用echo输出结果,调用时用$()捕获输出内容。
这种方式不受返回值范围限制,可以返回任意文本数据,是生产脚本的主流写法。函数的最后一条输出语句的结果会被捕获。
示例:计算两数之和,将结果返回给变量
#!/bin/bash
add() { echo $(( $1 + $2 ))}
# 捕获函数的输出,赋值给变量result=$(add 100 200)echo "计算结果是:$result"示例:返回复杂数据(字符串)
#!/bin/bash
get_system_info() { local hostname=$(hostname) local kernel=$(uname -r) local uptime=$(uptime | awk '{print $3}' | sed 's/,//')
# 返回格式化的字符串 echo "主机名:$hostname | 内核:$kernel | 运行时间:$uptime天"}
# 调用并使用返回值info=$(get_system_info)echo "系统信息:$info"示例:返回多行数据
#!/bin/bash
list_large_files() { local directory=$1 local size_threshold=${2:-100M} # 默认100MB
# find输出文件列表,echo捕获所有输出 find "$directory" -type f -size +$size_threshold 2>/dev/null | sort -rh}
# 调用并遍历返回的多行数据while read filepathdo echo "大文件:$filepath"done < <(list_large_files /var 50M)IMPORTANTreturn vs echo 选择指南:
场景 推荐方式 原因 返回成功/失败状态 return 符合Unix约定,可被if直接判断 返回单个整数结果 return 高效,直接通过$?获取 返回字符串 echo return无法直接返回字符串 返回多行数据 echo return范围限制,echo可返回任意内容 返回计算结果 echo 灵活性高,结果可能超过255 实战组合用法:函数同时返回状态码和数据
#!/bin/bashget_user_home() {local username=$1if id "$username" &>/dev/nullthen# 输出数据echo $(eval echo ~$username)return 0 # 返回成功状态elseecho "用户不存在"return 1 # 返回失败状态fi}# 调用时同时检查状态码和获取数据home=$(get_user_home "root")if [ $? -eq 0 ]thenecho "root用户主目录:$home"elseecho "获取主目录失败"fi
2.7 数据流转:输入/输出重定向与黑洞设备
在运维脚本中,我们经常需要把命令的输出保存到文件,或者屏蔽不需要的报错信息,这就需要用到重定向(Redirection)。
2.7.1 标准输入、输出与错误
Linux中一切皆文件,每个进程默认打开三个数据流:
- 0(STDIN):标准输入,默认是键盘输入。
- 1(STDOUT):标准输出,默认输出到屏幕。
- 2(STDERR):标准错误,报错信息,默认也输出到屏幕。
2.7.2 输出重定向:> 与 >>
>或1>:覆盖输出。把结果写入文件,原内容会被清空。>>或1>>:追加输出。把结果写到文件末尾。
echo "第一行" > log.txt # 创建并写入echo "第二行" >> log.txt # 追加到末尾2.7.3 错误重定向与黑洞设备
2>:专门把报错信息写入文件(正确的输出还在屏幕上)。2>&1或&>:合并输出。把正确和报错信息全部写入同一个文件(脚本日志记录必备)。/dev/null:系统黑洞。任何丢进这里的数据都会消失,常用于屏蔽不想看的输出。
# 屏蔽命令的所有输出(不论对错)ping -c 1 baidu.com &> /dev/null
# 后台运行服务,并记录所有日志./server_start.sh > app.log 2>&1 &2.7.4 输入重定向与 Here Document
<:从文件读取数据作为命令的输入。<<:Here Document。在脚本中直接写入多行文本块,极其适合自动生成配置文件!
#!/bin/bash# 自动生成 Nginx 配置文件cat << EOF > /etc/nginx/conf.d/myweb.confserver { listen 80; server_name www.example.com; root /var/www/html;}EOFecho "Nginx 配置生成完毕!"2.8 本章综合实战:完整的系统备份脚本
学完了本章的所有知识点,我们用一个综合脚本来实战应用,这个脚本涵盖了条件判断、循环、函数等核心内容。
#!/bin/bash# 完整的系统数据备份脚本# 支持多个备份源,压缩存储,定期清理过期备份
# ============ 配置段 ============BACKUP_BASE_DIR="/backup"BACKUP_RETENTION_DAYS=30 # 保留最近30天的备份LOG_FILE="$BACKUP_BASE_DIR/backup.log"
# 备份源配置(数组)declare -a BACKUP_SOURCES=( "/etc" "/home" "/root")
# ============ 函数定义 ============
# 初始化备份环境init_backup_env() { if [ ! -d "$BACKUP_BASE_DIR" ] then mkdir -p "$BACKUP_BASE_DIR" echo "[$(date '+%Y-%m-%d %H:%M:%S')] 备份目录不存在,已创建" | tee -a "$LOG_FILE" fi}
# 检查磁盘空间是否足够check_disk_space() { local required_space=$1 # MB local available_space=$(df "$BACKUP_BASE_DIR" | awk 'NR==2 {print $4}') # KB available_space=$((available_space / 1024)) # 转换为MB
if [ $available_space -lt $required_space ] then return 1 # 空间不足 else return 0 # 空间充足 fi}
# 备份单个源目录backup_source() { local source=$1 local backup_name=$(basename "$source") local timestamp=$(date '+%Y%m%d_%H%M%S') local backup_file="$BACKUP_BASE_DIR/${backup_name}_${timestamp}.tar.gz"
# 检查源是否存在 if [ ! -e "$source" ] then echo "[ERROR] 源路径不存在:$source" | tee -a "$LOG_FILE" return 1 fi
# 检查磁盘空间 local source_size=$(du -s "$source" 2>/dev/null | awk '{print $1}') source_size=$((source_size / 1024)) # 转换为MB
if ! check_disk_space $source_size then echo "[ERROR] 磁盘空间不足,无法备份$source(需要${source_size}MB)" | tee -a "$LOG_FILE" return 1 fi
# 开始备份 echo "[$(date '+%Y-%m-%d %H:%M:%S')] 开始备份:$source" | tee -a "$LOG_FILE"
tar -czf "$backup_file" -C "$(dirname "$source")" "$(basename "$source")" 2>/dev/null
if [ $? -eq 0 ] then local backup_size=$(du -h "$backup_file" | awk '{print $1}') echo "[SUCCESS] 备份完成:$backup_file (大小:$backup_size)" | tee -a "$LOG_FILE" return 0 else echo "[ERROR] 备份失败:$source" | tee -a "$LOG_FILE" rm -f "$backup_file" return 1 fi}
# 清理过期备份cleanup_old_backups() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] 开始清理过期备份(保留最近${BACKUP_RETENTION_DAYS}天)" | tee -a "$LOG_FILE"
local delete_count=0 while read backup_file do rm -f "$backup_file" echo "[CLEANUP] 已删除:$backup_file" | tee -a "$LOG_FILE" delete_count=$((delete_count + 1)) done < <(find "$BACKUP_BASE_DIR" -name "*.tar.gz" -type f -mtime +$BACKUP_RETENTION_DAYS)
if [ $delete_count -gt 0 ] then echo "[SUCCESS] 共清理过期备份${delete_count}个" | tee -a "$LOG_FILE" else echo "[INFO] 没有需要清理的过期备份" | tee -a "$LOG_FILE" fi}
# 显示备份统计信息show_backup_summary() { local total_backups=$(find "$BACKUP_BASE_DIR" -name "*.tar.gz" -type f | wc -l) local total_size=$(du -sh "$BACKUP_BASE_DIR" 2>/dev/null | awk '{print $1}')
echo "" | tee -a "$LOG_FILE" echo "===== 备份统计 =====" | tee -a "$LOG_FILE" echo "备份文件总数:$total_backups" | tee -a "$LOG_FILE" echo "备份总大小:$total_size" | tee -a "$LOG_FILE" echo "日志文件:$LOG_FILE" | tee -a "$LOG_FILE"}
# ============ 主程序 ============
main() { echo "===============================================" | tee -a "$LOG_FILE" echo "系统备份脚本启动 $(date '+%Y-%m-%d %H:%M:%S')" | tee -a "$LOG_FILE" echo "===============================================" | tee -a "$LOG_FILE"
# 初始化环境 init_backup_env
# 遍历所有备份源,依次备份 local success_count=0 local fail_count=0
for source in "${BACKUP_SOURCES[@]}" do if backup_source "$source" then success_count=$((success_count + 1)) else fail_count=$((fail_count + 1)) fi done
# 清理过期备份 cleanup_old_backups
# 显示总结 echo "" | tee -a "$LOG_FILE" echo "===== 备份结果 =====" | tee -a "$LOG_FILE" echo "成功:$success_count 个" | tee -a "$LOG_FILE" echo "失败:$fail_count 个" | tee -a "$LOG_FILE"
show_backup_summary
echo "===============================================" | tee -a "$LOG_FILE"
# 根据结果返回状态码 if [ $fail_count -eq 0 ] then echo "备份完成,所有备份源备份成功" | tee -a "$LOG_FILE" return 0 else echo "备份完成,但部分备份源备份失败" | tee -a "$LOG_FILE" return 1 fi}
# 执行主程序mainexit $?运行示例:
chmod +x backup.sh./backup.sh
# 输出:# ===============================================# 系统备份脚本启动 2024-11-29 15:30:45# ===============================================# [2024-11-29 15:30:45] 开始备份:/etc# [SUCCESS] 备份完成:/backup/etc_20241129_153045.tar.gz (大小:15M)# ...NOTE这个脚本展示了以下核心知识点:
- 数组定义和遍历(for循环)
- 函数定义和调用,参数传递
- 条件判断和文件测试(if-else)
- 函数返回值和调用后的状态检查
- 实际的日志记录和错误处理
- 业务流程的合理设计
2.9 本章知识点梳理与速查表
2.9.1 本章知识点总结
-
数值运算(2.1)
$((...))和let用于整数计算(推荐)。expr注意空格和乘号转义。bc结合管道|解决浮点数计算。
-
条件判断(2.2)
- 通过
[ 表达式 ]实现条件测试,分为文件测试、数值比较、字符串比较三类 - 文件测试:
-f(普通文件)、-d(目录)、-e(存在)、-r/-w/-x(权限) - 数值比较:
-eq、-ne、-gt、-lt、-ge、-le - 字符串比较:
=、!=、-z(空)、-n(非空) - if单分支、if-else双分支、if-elif-else多分支
- exit 提前终止脚本
- 通过
-
多值匹配(2.3)
- case语句适合固定值匹配场景,比多层if结构更简洁
- 使用
;;结束每个分支,*)作为默认分支 - 常用于菜单类脚本、参数检查
select语句能自动生成带编号的交互菜单
-
循环结构(2.4)
- for循环:已知循环次数或遍历列表,有两种写法(列表式和C语言式)
- while循环:条件为真就循环,适合逐行读文件、不确定次数的场景
- until循环:条件为假就循环,适合等待某状态达成的场景
-
循环控制(2.5)
- break:终止整个循环
- continue:仅跳过当前一轮循环,进入下一轮
-
函数封装(2.6)
- 函数定义:
function name {}或name() {} - 参数传递:
$1、$2、$#、$@ - 返回值两种方式:
return(返回0-255整数,代表状态码)echo(返回任意数据,企业常用)
- 使用
local关键字定义局部变量
- 函数定义:
-
重定向(2.7)
>覆盖,>>追加。2>&1或&>合并正确与错误输出。/dev/null系统黑洞设备。<< EOF生成多行文本配置。
2.9.2 常见语法对比速查表
# 条件测试快速参考[ -f /path/to/file ] # 文件存在[ -d /path/to/dir ] # 目录存在[ $a -gt $b ] # 数值比较[ "$str" = "value" ] # 字符串比较[ $a -gt 5 -a $b -lt 10 ] # 逻辑与[ $a -gt 5 -o $b -lt 10 ] # 逻辑或
# 循环快速参考for i in 1 2 3; do echo $i; done # 列表遍历for i in {1..10}; do echo $i; done # 序列遍历for (( i=1; i<=10; i++ )); do echo $i; done # C语言式while [ condition ]; do echo $i; done # while循环until [ condition ]; do echo $i; done # until循环
# 函数快速参考myfunc() { echo "hello"; } # 定义函数myfunc # 调用函数myfunc arg1 arg2 # 传参调用result=$(myfunc) # 捕获输出2.9.3 常见坑点总结
| 坑点 | 错误做法 | 正确做法 | 说明 |
|---|---|---|---|
| 方括号空格 | [$a -gt 10] | [ $a -gt 10 ] | 方括号两侧必须有空格 |
| 数值比较 | [ $a > $b ] | [ $a -gt $b ] | 不能用>/<,要用-gt/-lt |
| 字符串为空 | [ $var = "" ] | [ -z "$var" ] | 建议用-z判断空字符串 |
| 函数顺序 | 先调用后定义 | 先定义后调用 | Shell顺序执行,函数必须先定义 |
| 函数参数 | myfunc() | myfunc arg1 arg2 | 调用时不需要括号,参数空格分隔 |
| 返回数据 | 用return返回字符串 | 用echo返回数据 | return只支持0-255整数 |
2.10 进阶练习与扩展
练习1:监控脚本(综合应用条件判断+循环+函数)
#!/bin/bash# 系统监控告警脚本,定期检查CPU、内存、磁盘状态
# 告警阈值配置CPU_THRESHOLD=80MEM_THRESHOLD=80DISK_THRESHOLD=85
# 检查CPU使用率check_cpu() { local cpu_usage=$(top -bn1 | grep "Cpu(s)" | awk '{print int($2)}')
if [ $cpu_usage -gt $CPU_THRESHOLD ] then echo "警告:CPU使用率过高 - ${cpu_usage}%" return 1 else echo "正常:CPU使用率 - ${cpu_usage}%" return 0 fi}
# 检查内存使用率check_memory() { local mem_usage=$(free | grep Mem | awk '{printf("%.0f\n", ($3/$2)*100)}')
if [ $mem_usage -gt $MEM_THRESHOLD ] then echo "警告:内存使用率过高 - ${mem_usage}%" return 1 else echo "正常:内存使用率 - ${mem_usage}%" return 0 fi}
# 检查磁盘使用率check_disk() { local disk_usage=$(df / | awk 'NR==2 {print $5}' | sed 's/%//')
if [ $disk_usage -gt $DISK_THRESHOLD ] then echo "警告:磁盘使用率过高 - ${disk_usage}%" return 1 else echo "正常:磁盘使用率 - ${disk_usage}%" return 0 fi}
# 主监控函数main() { echo "===== 系统监控开始 $(date '+%Y-%m-%d %H:%M:%S') ====="
local warn_count=0
if ! check_cpu; then warn_count=$((warn_count+1)); fi if ! check_memory; then warn_count=$((warn_count+1)); fi if ! check_disk; then warn_count=$((warn_count+1)); fi
echo "===== 监控完成,告警数:$warn_count ====="
return $warn_count}
main练习2:日志分析脚本(结合while循环逐行处理)
#!/bin/bash# 分析Apache访问日志,统计IP访问频率和错误日志
log_file="/var/log/apache2/access.log"error_threshold=5
if [ ! -f "$log_file" ]then echo "日志文件不存在:$log_file" exit 1fi
echo "===== 日志分析开始 ====="echo ""
# 方式1:统计访问频率最高的10个IPecho "访问频率最高的Top10 IP:"awk '{print $1}' "$log_file" | sort | uniq -c | sort -rn | head -10 | while read count ipdo echo " $ip: $count次访问"done
echo ""
# 方式2:统计HTTP状态码分布echo "HTTP状态码分布:"awk '{print $9}' "$log_file" | sort | uniq -c | sort -rn | while read count statusdo case $status in 200|304) echo " $status(成功): $count次" ;; 3*) echo " $status(重定向): $count次" ;; 4*) echo " $status(客户端错误): $count次" ;; 5*) echo " $status(服务器错误): $count次" ;; *) echo " $status: $count次" ;; esacdone
echo ""echo "===== 日志分析完成 ====="。
练习3:简易计算器与菜单选择(综合应用select、数值运算)
#!/bin/bash# 交互式简易计算器,支持浮点数
PS3="请选择计算模式: "select opt in "加法" "减法" "乘法" "除法" "退出"do if [ "$opt" == "退出" ] then echo "再见!" exit 0 fi
# 校验是否选择了有效菜单 if [ -z "$opt" ] then echo "无效选择,请重新输入编号" continue fi
read -p "请输入两个数字(空格分隔): " num1 num2
case $opt in "加法") echo "$num1 + $num2 = $(echo "$num1 + $num2" | bc)" ;; "减法") echo "$num1 - $num2 = $(echo "$num1 - $num2" | bc)" ;; "乘法") echo "$num1 * $num2 = $(echo "$num1 * $num2" | bc)" ;; "除法") echo "$num1 / $num2 = $(echo "scale=2; $num1 / $num2" | bc)" ;; esacdone