函数与数组 — 写出可维护的 Shell 脚本

📅 2026/6/28 23:18:07
函数与数组 — 写出可维护的 Shell 脚本
函数与数组——写出可维护的 Shell 脚本 系列Shell 脚本系列第4篇 笔名厕所里的思想家—## 一、开篇引子前几篇我们学了变量、条件判断、循环按理说已经能写出能用的脚本了。但你有没有发现一个问题——脚本一长代码就开始乱了比如你写了一段日志输出的代码每次都要敲一遍echo [$(date %Y-%m-%d %H:%M:%S)] 信息xxxx。复制粘贴倒也行但万一哪天想改日志格式——比如加个日志级别——你得满脚本去找改几十个地方漏一个就乱了。这不就跟家里东西乱放一个道理吗如果每个工具都有固定的抽屉想用的时候拉开就拿用完放回去多省心。函数就是你的抽屉——把常用操作封装起来命名好随叫随到。数组就是你的收纳盒——把一堆相关的数据放一起统一处理。这篇学完你写的脚本就会从能用升级到好维护。走起。—## 二、Shell 函数### 2.1 函数定义与调用Shell 函数的定义有两种写法效果完全一样bash#!/bin/bash# 方式一function 关键字可读性更好function say_hello { echo 你好世界}# 方式二括号形式更简洁推荐say_hi() { echo 嗨你好}# 调用函数——直接写函数名就行say_hellosay_hi执行结果你好世界嗨你好注意调用函数时不要加括号say_hello()是函数定义say_hello才是函数调用。很多人刚开始搞混写成say_hello()来调用Shell 会报错。而且函数定义必须出现在调用之前——Shell 是顺序执行的它还没读到函数定义的时候根本不知道say_hello是个啥。### 2.2 函数参数$1, $2, $, $*函数也可以接受参数方式和脚本参数一模一样——用$1、$2……$、$*、$#bash#!/bin/bash# 带参数的函数greet() { echo 你好$1你今年 $2 岁了。 echo 你总共传了 $# 个参数}# 调用并传参greet 小明 25执行结果你好小明你今年 25 岁了。你总共传了 2 个参数来咱们写个实用点的——批量重命名打招呼bash#!/bin/bash# 遍历传入的所有名字挨个打招呼greet_all() { for name in $; do echo 欢迎你$name done}greet_all 张三 李四 王五 赵六执行结果欢迎你张三欢迎你李四欢迎你王五欢迎你赵六这里$会把每个参数都当作独立个体而$*会合成一个字符串。在遍历时永远用$别问为什么记住就行——我当年吃过这个亏。### 2.3 返回值与 returnShell 函数的return和大多数编程语言不一样它只能返回 0~255 的整数而且这个值本质上是退出状态码不是通常意义上的返回值。bash#!/bin/bash# 判断数字是正是负check_number() { if [ $1 -gt 0 ]; then return 0 # 正数返回 0成功 elif [ $1 -lt 0 ]; then return 1 # 负数返回 1失败 else return 2 # 零 fi}check_number 5echo 退出码$? # $? 获取上一条命令的退出码check_number -3echo 退出码$?check_number 0echo 退出码$?执行结果退出码0退出码1退出码2看到了吗$?取到的是函数return的值但这个值取值范围很小。假如你要回传一个数字怎么办比如你写了个加法函数想返回计算结果——return可就尴尬了因为 256 以上的值会溢出。正确的做法是输出到标准输出用$()捕获bash#!/bin/bash# 正确的返回值姿势——用 echo 命令替换add() { local sum$(( $1 $2 )) echo $sum # 输出到标准输出}result$(add 10 20) # $() 捕获函数输出echo 10 20 $result# 再算个大的result$(add 1000 2000)echo 1000 2000 $result执行结果10 20 301000 2000 3000总结一下| 场景 | 用什么 ||------|--------|| 返回成功/失败状态 |return 0/return 1调用方用$?获取 || 返回数值/字符串结果 |echo 结果值调用方用$()捕获 || 两者都需要 |echo输出结果 return返回状态码 |### 2.4 局部变量 local这是初学者最容易忽略的一个点。默认情况下函数里定义的变量是全局的——函数外面也能访问函数执行完后变量仍然存在。这就带来了变量污染的风险——函数里不小心改了一个跟外面同名的变量bug 就悄悄地来了。bash#!/bin/bash# 不加 local 的惨痛教训name全局老王change_name() { name局部小李 # 没有 local直接修改了全局变量 echo 函数内部$name}echo 调用前$namechange_nameecho 调用后$name # 完了全局变量被改了执行结果调用前全局老王函数内部局部小李调用后局部小李 # 全局变量被污染了加上local之后变量只在函数内部生效bash#!/bin/bashname全局老王change_name() { local name局部小李 # 加 local互不干扰 echo 函数内部$name}echo 调用前$namechange_nameecho 调用后$name # 还是老王稳如老狗执行结果调用前全局老王函数内部局部小李调用后全局老王规则很简单函数内部用的变量一律加local。养成这个习惯能帮你避免无数莫名其妙的 bug。### 2.5 实战日志函数 错误处理函数来看一个真实项目里几乎一定会用的东西——日志函数bash#!/bin/bash# 文件名logger_demo.sh# 日志函数统一输出格式方便以后调整log_info() { local msg$1 echo [$(date %Y-%m-%d %H:%M:%S)] [INFO] $msg}log_warn() { local msg$1 echo [$(date %Y-%m-%d %H:%M:%S)] [WARN] $msg}log_error() { local msg$1 echo [$(date %Y-%m-%d %H:%M:%S)] [ERROR] $msg 2 # 2 输出到标准错误}# 错误处理函数检查命令执行结果check_result() { if [ $? -ne 0 ]; then log_error $1 失败 exit 1 fi}# ---- 使用示例 ----log_info 开始部署应用...# 模拟一个操作mkdir -p /tmp/test_appcheck_result 创建目录 # 如果 mkdir 成功了$? 为 0check_result 什么都不做log_info 正在复制文件...cp -r ./src /tmp/test_app/check_result 复制文件log_info 部署完成运行效果[2026-06-26 22:00:00] [INFO] 开始部署应用...[2026-06-26 22:00:00] [INFO] 正在复制文件...[2026-06-26 22:00:00] [INFO] 部署完成这段代码的价值在哪里- 所有日志格式统一想改时间格式只改一个地方- 错误处理集中到check_result不用每次写if [ $? -ne 0 ]; then ...- 一旦某步失败脚本自动退出不会接着往下跑这就叫可维护性。—## 三、Shell 数组### 3.1 数组定义与访问Shell 数组用括号()定义元素之间用空格分隔bash#!/bin/bash# 数组定义fruits(苹果 香蕉 橘子 西瓜)# 访问单个元素——下标从 0 开始echo 第一个水果${fruits[0]}echo 第二个水果${fruits[1]}echo 第三个水果${fruits[2]}# 访问所有元素echo 所有水果${fruits[]}# 访问下标越界——不会报错返回空echo 第10个水果${fruits[10]:-不存在}执行结果第一个水果苹果第二个水果香蕉第三个水果橘子所有水果苹果 香蕉 橘子 西瓜第10个水果不存在注意访问数组元素一定要用${}花括号写成$fruits[0]的话Shell 会把$fruits展开成第一个元素后面跟一个[0]——完全不是你想要的。### 3.2 遍历数组遍历数组最常用的就是用for循环bash#!/bin/bashservers(web01 web02 db01 cache01)echo 服务器列表 for server in ${servers[]}; do echo - $serverdone执行结果 服务器列表 - web01 - web02 - db01 - cache01还可以用下标遍历——适合需要知道索引的场景bash#!/bin/bashscores(85 92 78 95 88)for i in ${!scores[]}; do # ${!scores[]} 获取所有下标 echo 第 $((i 1)) 个学生的成绩${scores[$i]}done执行结果第 1 个学生的成绩85第 2 个学生的成绩92第 3 个学生的成绩78第 4 个学生的成绩95第 5 个学生的成绩88注意${!scores[]}的感叹号表示取下标列表不是取反。这个感叹号在不同的上下文里意思不一样——这里就是给我下标。### 3.3 数组常用操作长度/追加/删除/切片获取数组长度bash#!/bin/bashcolors(红 绿 蓝)echo 数组长度${#colors[]} # 输出3echo 第一个元素长度${#colors[0]} # 输出1红是一个字追加元素bash#!/bin/bashcolors(红 绿 蓝)colors(黄 紫) # 一次性追加多个echo ${colors[]} # 输出红 绿 蓝 黄 紫删除元素用 unsetbash#!/bin/bashnumbers(10 20 30 40 50)unset numbers[2] # 删除第3个元素下标 2echo ${numbers[]} # 输出10 20 40 50echo 下标列表${!numbers[]} # 输出0 1 3 4注意下标 2 被删了删掉中间的元素之后数组中会留下一个空洞——后面元素的下标不会自动往前挪。遍历时用${!numbers[]}拿有效下标就对了。切片提取子数组bash#!/bin/bashletters(a b c d e f)# ${数组[]:起始下标:个数}echo ${letters[]:0:3} # 输出a b c前3个echo ${letters[]:3:2} # 输出d e第4个开始的2个echo ${letters[]:2} # 输出c d e f第3个开始一直到尾执行结果a b cd ec d e f### 3.4 关联数组declare -A关联数组就是键值对——用字符串当下标而不是数字bash#!/bin/bash# 声明关联数组——必须用 declare -Adeclare -A user# 赋值user[name]张三user[age]28user[city]北京# 访问echo 姓名${user[name]}echo 年龄${user[age]}echo 城市${user[city]}# 遍历所有键和值echo echo 用户信息 for key in ${!user[]}; do echo $key${user[$key]}done执行结果姓名张三年龄28城市北京 用户信息 name张三 age28 city北京注意这个declare -A非常重要——不声明的话Shell 会当普通数组处理user[name]就变成了user[0]全乱套了。关联数组在处理配置映射时特别有用bash#!/bin/bash# 服务端口映射declare -A service_portsservice_ports[nginx]80service_ports[mysql]3306service_ports[redis]6379service_ports[ssh]22for svc in ${!service_ports[]}; do echo $svc 运行在端口 ${service_ports[$svc]}done执行结果nginx 运行在端口 80mysql 运行在端口 3306redis 运行在端口 6379ssh 运行在端口 22—## 四、函数数组实战### 4.1 批量文件处理脚本假设你有个目录里面一堆.log文件你想批量压缩超过 7 天的日志文件然后删除压缩前的源文件。这在实际运维中再常见不过了bash#!/bin/bash# 文件名archive_old_logs.sh# 用途压缩并归档7天前的日志文件# ---- 配置区 ----LOG_DIR/var/log/myappARCHIVE_DIR/var/log/archivesRETENTION_DAYS7# ---- 函数定义 ----log_info() { local msg$1 echo [$(date %Y-%m-%d %H:%M:%S)] [INFO] $msg}log_error() { local msg$1 echo [$(date %Y-%m-%d %H:%M:%S)] [ERROR] $msg 2}check_result() { if [ $? -ne 0 ]; then log_error $1 exit 1 fi}# ---- 主逻辑 ----log_info 开始日志归档任务...# 用数组收集所有 .log 文件log_files($LOG_DIR/*.log)# 检查有没有日志文件if [ ${#log_files[]} -eq 0 ] || [ ! -f ${log_files[0]} ]; then log_warn没有找到 .log 文件 echo $(date) [WARN] $log_warn exit 0filog_info 找到 ${#log_files[]} 个日志文件# 确保归档目录存在mkdir -p $ARCHIVE_DIR# 遍历处理每个文件for file in ${log_files[]}; do # 获取文件的修改时间Unix 时间戳 file_time$(stat -c %Y $file 2/dev/null) current_time$(date %s) file_days_old$(( (current_time - file_time) / 86400 )) if [ $file_days_old -ge $RETENTION_DAYS ]; then filename$(basename $file) archive_name$(basename $filename .log)_$(date %Y%m%d).tar.gz log_info 正在归档$filename已 $file_days_old 天 # 压缩文件 tar -czf $ARCHIVE_DIR/$archive_name -C $LOG_DIR $filename check_result 压缩 $filename 失败 # 删除原文件 rm $file check_result 删除 $filename 失败 log_info 归档完成$archive_name else log_info 跳过$(basename $file)仅 $file_days_old 天 fidonelog_info 日志归档任务结束这个脚本把前几篇学的知识点全都用上了——函数封装日志逻辑、数组收集文件列表、条件判断过滤文件、循环批量处理。写出来结构清晰读完就知道每一步在干啥。### 4.2 菜单选择系统这是另一个很常见的场景——写一个交互式运维工具让同事点点数字就能执行任务不用记命令bash#!/bin/bash# 文件名ops_menu.sh# 用途简单的运维菜单管理工具# ---- 菜单数据用关联数组保存 ----declare -A menu_itemsmenu_items[1]查看系统信息menu_items[2]检查磁盘空间menu_items[3]查看当前连接数menu_items[4]清理临时文件menu_items[5]退出# ---- 函数定义 ----show_menu() { echo echo 运维工具箱 for key in $(echo ${!menu_items[]} | tr \n | sort); do echo $key. ${menu_items[$key]} done echo }do_task() { local choice$1 case $choice in 1) echo echo --- 系统信息 --- echo 主机名$(hostname) echo 系统版本$(cat /etc/os-release 2/dev/null | head -1 || echo 未知) echo 运行时间$(uptime -p) echo 内存总量$(free -h | awk /^Mem:/{print $2}) echo --- 系统信息 --- ;; 2) echo echo --- 磁盘使用情况 --- df -h | grep -v tmpfs echo --- 磁盘使用情况 --- ;; 3) echo echo --- 当前连接数 --- ss -tun | tail -n 2 | awk {print $5} | cut -d: -f1 | sort | uniq -c | sort -rn | head -10 echo --- 当前连接数 --- ;; 4) echo echo --- 清理 /tmp 下 7 天前的临时文件 --- local count0 for f in /tmp/*.tmp; do if [ -f $f ]; then local age$(( ($(date %s) - $(stat -c %Y $f)) / 86400 )) if [ $age -ge 7 ]; then rm $f count$((count 1)) fi fi done echo 已清理 $count 个临时文件 ;; 5) echo 再见 exit 0 ;; *) echo 无效选项请重新选择。 ;; esac}# ---- 主循环 ----while true; do show_menu read -p 请输入选项 [1-5] choice do_task $choicedone用这种方式写的好处是- 添加新功能只需要在menu_items加一项在do_task里加一个分支- 菜单显示和任务执行完全分开修改显示格式不影响功能- 任何人跑这个脚本都能操作不用去记df -h、ss -tun这些命令—## 五、总结好今天的内容就到这儿。来简单回顾一下函数篇- 定义函数函数名() { ... }或者function 函数名 { ... }- 调用函数直接写函数名不要加括号- 参数传递$1、$2……$、$#和脚本参数一模一样-return只能返回 0~255 的退出码要返回值用echo$()命令替换-所有函数内部变量都要加local避免污染全局变量数组篇- 定义数组(元素1 元素2 ...)- 访问${数组[下标]}、${数组[]}全部元素- 遍历for v in ${数组[]}或for i in ${!数组[]}- 常用操作${#数组[]}长度、数组(新元素)追加、unset 数组[下标]删除、${数组[]:起始:个数}切片- 关联数组declare -A声明用字符串当下标函数 数组的组合拳就能写出结构清晰、易于维护的脚本。再也不用把几千行代码全塞在main里了——把逻辑拆成函数把数据装进数组脚本的可读性和复用性瞬间提升一个档次。下期预告我们会深入讲字符串处理和文件读写——sed、awk、grep这些文本处理三剑客以及怎么在 Shell 里读写配置文件。这些都是日常运维中最实用的技能敬请期待。如果这篇对你有帮助欢迎收藏转发我们下篇见。